Compare commits

..

3 Commits

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

View File

@@ -0,0 +1,36 @@
name: Backend Tests
on:
push:
paths:
- 'backend/**'
pull_request:
paths:
- 'backend/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> "${GITEA_OUTPUT:-$GITHUB_OUTPUT}"
- uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run Backend Tests
run: pnpm -F @memegoat/backend test

View File

@@ -1,18 +1,13 @@
# Pipeline CI/CD pour Gitea Actions (Forgejo)
# Compatible avec GitHub Actions pour la portabilité
name: CI/CD Pipeline
name: Deploy to Production
on:
push:
branches:
- '**'
tags:
- 'v*'
pull_request:
- prod
jobs:
validate:
name: Valider ${{ matrix.component }}
name: Validate Build & Lint
runs-on: ubuntu-latest
strategy:
matrix:
@@ -21,23 +16,23 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Installer pnpm
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Configurer Node.js
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Obtenir le chemin du store pnpm
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> "${GITEA_OUTPUT:-$GITHUB_OUTPUT}"
- name: Configurer le cache pnpm
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
@@ -45,45 +40,28 @@ jobs:
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Installer les dépendances
run: pnpm install --frozen-lockfile --prefer-offline
- name: Install dependencies
run: pnpm install --frozen-lockfile
- 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
name: Deploy to 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
- name: Deploy with Docker Compose
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
docker compose -f docker-compose.prod.yml up -d --build
env:
BACKEND_PORT: ${{ secrets.BACKEND_PORT }}
FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }}

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

@@ -0,0 +1,43 @@
name: Lint
on:
push:
paths:
- 'frontend/**'
- 'backend/**'
- 'documentation/**'
pull_request:
paths:
- 'frontend/**'
- 'backend/**'
- 'documentation/**'
jobs:
lint:
runs-on: ubuntu-latest
strategy:
matrix:
component: [backend, frontend, documentation]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> "${GITEA_OUTPUT:-$GITHUB_OUTPUT}"
- uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint ${{ matrix.component }}
run: pnpm -F @memegoat/${{ matrix.component }} lint

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@memegoat/backend",
"version": "1.4.0",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
@@ -13,7 +13,7 @@
"scripts": {
"build": "nest build",
"lint": "biome check",
"lint:write": "biome check --write --unsafe",
"lint:write": "biome check --write",
"format": "biome format --write",
"start": "nest start",
"start:dev": "nest start --watch",
@@ -107,7 +107,7 @@
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"transformIgnorePatterns": [
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid))"
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid)/)"
],
"transform": {
"^.+\\.(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 { CommonModule } from "./common/common.module";
import { CrawlerDetectionMiddleware } from "./common/middlewares/crawler-detection.middleware";
import { HTTPLoggerMiddleware } from "./common/middlewares/http-logger.middleware";
import { validateEnv } from "./config/env.schema";
import { ContentsModule } from "./contents/contents.module";
import { CryptoModule } from "./crypto/crypto.module";
@@ -77,8 +76,6 @@ import { UsersModule } from "./users/users.module";
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(HTTPLoggerMiddleware, CrawlerDetectionMiddleware)
.forRoutes("*");
consumer.apply(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 {
Body,
Controller,
Get,
Headers,
Post,
Query,
Req,
Res,
} from "@nestjs/common";
import { Body, Controller, Headers, Post, Req, Res } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Throttle } from "@nestjs/throttler";
import type { Request, Response } from "express";
import { getIronSession } from "iron-session";
import { AuthService } from "./auth.service";
import { BootstrapService } from "./bootstrap.service";
import { LoginDto } from "./dto/login.dto";
import { RegisterDto } from "./dto/register.dto";
import { Verify2faDto } from "./dto/verify-2fa.dto";
@@ -23,7 +13,6 @@ import { getSessionOptions, SessionData } from "./session.config";
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly bootstrapService: BootstrapService,
private readonly configService: ConfigService,
) {}
@@ -131,12 +120,4 @@ export class AuthController {
session.destroy();
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 { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { BootstrapService } from "./bootstrap.service";
import { AuthGuard } from "./guards/auth.guard";
import { OptionalAuthGuard } from "./guards/optional-auth.guard";
import { RolesGuard } from "./guards/roles.guard";
@@ -16,7 +15,6 @@ import { RbacRepository } from "./repositories/rbac.repository";
providers: [
AuthService,
RbacService,
BootstrapService,
RbacRepository,
AuthGuard,
OptionalAuthGuard,

View File

@@ -110,7 +110,6 @@ export class AuthService {
const user = await this.usersService.findByEmailHash(emailHash);
if (!user) {
this.logger.warn(`Login failed: user not found for email hash`);
throw new UnauthorizedException("Invalid credentials");
}
@@ -120,12 +119,10 @@ export class AuthService {
);
if (!isPasswordValid) {
this.logger.warn(`Login failed: invalid password for user ${user.uuid}`);
throw new UnauthorizedException("Invalid credentials");
}
if (user.isTwoFactorEnabled) {
this.logger.log(`2FA required for user ${user.uuid}`);
return {
message: "2FA required",
requires2FA: true,
@@ -144,7 +141,6 @@ export class AuthService {
ip,
);
this.logger.log(`User ${user.uuid} logged in successfully`);
return {
message: "User logged in successfully",
access_token: accessToken,
@@ -169,9 +165,6 @@ export class AuthService {
const isValid = authenticator.verify({ token, secret });
if (!isValid) {
this.logger.warn(
`2FA verification failed for user ${userId}: invalid token`,
);
throw new UnauthorizedException("Invalid 2FA token");
}
@@ -186,7 +179,6 @@ export class AuthService {
ip,
);
this.logger.log(`User ${userId} logged in successfully via 2FA`);
return {
message: "User logged in successfully (2FA)",
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 = {
findRolesByUserId: jest.fn(),
findPermissionsByUserId: jest.fn(),
countRoles: jest.fn(),
createRole: jest.fn(),
};
beforeEach(async () => {
@@ -60,35 +58,4 @@ describe("RbacService", () => {
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";
@Injectable()
export class RbacService implements OnApplicationBootstrap {
private readonly logger = new Logger(RbacService.name);
export class RbacService {
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) {
return this.rbacRepository.findRolesByUserId(userId);
}
@@ -55,12 +12,4 @@ export class RbacService implements OnApplicationBootstrap {
async getUserPermissions(userId: string) {
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)));
}
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 { Request, Response } from "express";
interface RequestWithUser extends Request {
user?: {
sub?: string;
username?: string;
id?: string;
};
}
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger("ExceptionFilter");
@@ -24,7 +16,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<RequestWithUser>();
const request = ctx.getRequest<Request>();
const status =
exception instanceof HttpException
@@ -36,9 +28,6 @@ export class AllExceptionsFilter implements ExceptionFilter {
? exception.getResponse()
: "Internal server error";
const userId = request.user?.sub || request.user?.id;
const userPart = userId ? `[User: ${userId}] ` : "";
const errorResponse = {
statusCode: status,
timestamp: new Date().toISOString(),
@@ -53,12 +42,12 @@ export class AllExceptionsFilter implements ExceptionFilter {
if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
Sentry.captureException(exception);
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 : "",
);
} else {
this.logger.warn(
`${userPart}${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`,
`${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`,
);
}

View File

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

View File

@@ -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

@@ -10,7 +10,6 @@ import {
Param,
ParseBoolPipe,
ParseIntPipe,
Patch,
Post,
Query,
Req,
@@ -174,16 +173,6 @@ export class ContentsController {
return this.contentsService.incrementUsage(id);
}
@Patch(":id")
@UseGuards(AuthGuard)
update(
@Param("id") id: string,
@Req() req: AuthenticatedRequest,
@Body() updateContentDto: any,
) {
return this.contentsService.update(id, req.user.sub, updateContentDto);
}
@Delete(":id")
@UseGuards(AuthGuard)
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
@@ -196,11 +185,4 @@ export class ContentsController {
removeAdmin(@Param("id") id: string) {
return this.contentsService.removeAdmin(id);
}
@Patch(":id/admin")
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin")
updateAdmin(@Param("id") id: string, @Body() updateContentDto: any) {
return this.contentsService.updateAdmin(id, updateContentDto);
}
}

View File

@@ -23,7 +23,6 @@ describe("ContentsService", () => {
incrementViews: jest.fn(),
incrementUsage: jest.fn(),
softDelete: jest.fn(),
softDeleteAdmin: jest.fn(),
findOne: jest.fn(),
findBySlug: jest.fn(),
};
@@ -148,81 +147,4 @@ describe("ContentsService", () => {
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
const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`;
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
this.logger.log(`File uploaded successfully to S3: ${key}`);
// 4. Création en base de données
return await this.create(userId, {
@@ -184,35 +183,6 @@ export class ContentsService {
return deleted;
}
async updateAdmin(id: string, data: any) {
this.logger.log(`Updating content ${id} by admin`);
const updated = await this.contentsRepository.update(id, data);
if (updated) {
await this.clearContentsCache();
}
return updated;
}
async update(id: string, userId: string, data: any) {
this.logger.log(`Updating content ${id} for user ${userId}`);
// Vérifier que le contenu appartient à l'utilisateur
const existing = await this.contentsRepository.findOne(id, userId);
if (!existing || existing.userId !== userId) {
throw new BadRequestException(
"Contenu non trouvé ou vous n'avez pas la permission de le modifier.",
);
}
const updated = await this.contentsRepository.update(id, data);
if (updated) {
await this.clearContentsCache();
}
return updated;
}
async findOne(idOrSlug: string, userId?: string) {
const content = await this.contentsRepository.findOne(idOrSlug, userId);
if (!content) return null;

View File

@@ -404,15 +404,6 @@ export class ContentsRepository {
return deleted;
}
async update(id: string, data: Partial<typeof contents.$inferInsert>) {
const [updated] = await this.databaseService.db
.update(contents)
.set({ ...data, updatedAt: new Date() })
.where(eq(contents.id, id))
.returning();
return updated;
}
async findBySlug(slug: string) {
const [result] = await this.databaseService.db
.select()

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

View File

@@ -1,44 +1,28 @@
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Controller, Get, Inject } from "@nestjs/common";
import type { Cache } from "cache-manager";
import { Controller, Get } from "@nestjs/common";
import { sql } from "drizzle-orm";
import { DatabaseService } from "./database/database.service";
@Controller("health")
export class HealthController {
constructor(
private readonly databaseService: DatabaseService,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {}
constructor(private readonly databaseService: DatabaseService) {}
@Get()
async check() {
const health: any = {
status: "ok",
timestamp: new Date().toISOString(),
};
try {
// Check database connection
await this.databaseService.db.execute(sql`SELECT 1`);
health.database = "connected";
return {
status: "ok",
database: "connected",
timestamp: new Date().toISOString(),
};
} catch (error) {
health.status = "error";
health.database = "disconnected";
health.databaseError = error.message;
return {
status: "error",
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,12 +1,12 @@
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;
let s3Service: S3Service;
const mockS3Service = {
getFileInfo: jest.fn(),
@@ -20,6 +20,7 @@ describe("MediaController", () => {
}).compile();
controller = module.get<MediaController>(MediaController);
s3Service = module.get<S3Service>(S3Service);
});
it("should be defined", () => {
@@ -27,13 +28,12 @@ describe("MediaController", () => {
});
describe("getFile", () => {
it("should stream the file and set headers with path containing slashes", async () => {
it("should stream the file and set headers", async () => {
const res = {
setHeader: jest.fn(),
} as unknown as Response;
} as any;
const stream = new Readable();
stream.pipe = jest.fn();
const key = "contents/user-id/test.webp";
mockS3Service.getFileInfo.mockResolvedValue({
size: 100,
@@ -41,22 +41,16 @@ describe("MediaController", () => {
});
mockS3Service.getFile.mockResolvedValue(stream);
await controller.getFile(key, res);
await controller.getFile("test.webp", res);
expect(mockS3Service.getFileInfo).toHaveBeenCalledWith(key);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/webp");
expect(res.setHeader).toHaveBeenCalledWith("Content-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;
const res = {} as any;
await expect(controller.getFile("invalid", res)).rejects.toThrow(
NotFoundException,

View File

@@ -1,47 +1,26 @@
import {
Controller,
Get,
Logger,
NotFoundException,
Query,
Res,
} from "@nestjs/common";
import { Controller, Get, NotFoundException, Param, Res } from "@nestjs/common";
import type { Response } from "express";
import type { BucketItemStat } from "minio";
import { S3Service } from "../s3/s3.service";
@Controller("media")
export class MediaController {
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");
}
@Get("*key")
async getFile(@Param("key") key: string, @Res() res: Response) {
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 stats = await this.s3Service.getFileInfo(key);
const stream = await this.s3Service.getFile(key);
const contentType: string =
stats.metaData?.["content-type"] ||
stats.metaData?.["Content-Type"] ||
"application/octet-stream";
res.setHeader("Content-Type", contentType);
res.setHeader(
"Content-Type",
stats.metaData["content-type"] || "application/octet-stream",
);
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}`,
);
} catch (_error) {
throw new NotFoundException("Fichier non trouvé");
}
}

View File

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

View File

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

@@ -14,12 +14,4 @@ export class UpdateUserDto {
@IsOptional()
@IsString()
avatarUrl?: string;
@IsOptional()
@IsString()
status?: "active" | "verification" | "suspended" | "pending" | "deleted";
@IsOptional()
@IsString()
role?: string;
}

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

@@ -64,7 +64,7 @@ export class UsersRepository {
}
async findAll(limit: number, offset: number) {
const result = await this.databaseService.db
return await this.databaseService.db
.select({
uuid: users.uuid,
username: users.username,
@@ -77,8 +77,6 @@ export class UsersRepository {
.from(users)
.limit(limit)
.offset(offset);
return result;
}
async findByUsername(username: string) {

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

@@ -112,16 +112,6 @@ export class UsersController {
return this.usersService.remove(uuid);
}
@Patch("admin/:uuid")
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin")
updateAdmin(
@Param("uuid") uuid: string,
@Body() updateUserDto: UpdateUserDto,
) {
return this.usersService.update(uuid, updateUserDto);
}
// Double Authentification (2FA)
@Post("me/2fa/setup")
@UseGuards(AuthGuard)

View File

@@ -128,112 +128,4 @@ describe("UsersService", () => {
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

@@ -100,14 +100,7 @@ export class UsersService {
async update(uuid: string, data: UpdateUserDto) {
this.logger.log(`Updating user profile for ${uuid}`);
const { role, ...userData } = data;
const result = await this.usersRepository.update(uuid, userData);
if (role) {
await this.rbacService.assignRoleToUser(uuid, role);
}
const result = await this.usersRepository.update(uuid, data);
if (result[0]) {
await this.clearUserCache(result[0].username);
@@ -150,7 +143,6 @@ export class UsersService {
// 3. Upload vers S3
const key = `avatars/${uuid}/${Date.now()}-${uuidv4()}.${processed.extension}`;
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
this.logger.log(`Avatar uploaded successfully to S3: ${key}`);
// 4. Mise à jour de la base de données
const user = await this.update(uuid, { avatarUrl: key });

View File

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

View File

@@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1
# syntax=docker.io/docker/dockerfile:1
FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm"
@@ -11,20 +11,11 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY backend/package.json ./backend/
COPY frontend/package.json ./frontend/
COPY documentation/package.json ./documentation/
# Montage du cache pnpm
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
RUN pnpm install --no-frozen-lockfile
COPY . .
# Deuxième passe avec cache pour les scripts/liens
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
# Build avec cache Next.js
RUN --mount=type=cache,id=next-docs-cache,target=/usr/src/app/documentation/.next/cache \
pnpm run --filter @memegoat/documentation build
# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects
RUN pnpm install --no-frozen-lockfile
RUN pnpm run --filter @memegoat/documentation build
FROM node:22-alpine AS runner
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.
**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.
- `password` (string, min: 8) : Mot de passe.
- `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.
- `password` (string) : Mot de passe (min. 8 caractères).
```json
{
"username": "goat_user",
"email": "user@memegoat.fr",
"password": "strong-password",
"displayName": "Le Bouc"
"password": "strong-password"
}
```
</Accordion>
@@ -44,25 +38,23 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
- `email` (string)
- `password` (string)
**Réponses :**
- `200 OK` : Connexion réussie.
```json
{
"message": "User logged in successfully",
"userId": "uuid-v4"
}
```
- `200 OK` (2FA requise) :
```json
{
"message": "2FA required",
"requires2FA": true,
"userId": "uuid-v4"
}
```
- `401 Unauthorized` : Identifiants invalides.
**Réponse (Succès) :**
```json
{
"message": "User logged in successfully",
"userId": "uuid-v4"
}
```
*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 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 :**
- `userId` (uuid) : ID de l'utilisateur.
- `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 title="POST /auth/refresh">
Obtient un nouvel `access_token` à partir du `refresh_token` stocké dans la session.
Met à jour automatiquement le cookie de session.
**Réponses :**
- `200 OK` : Token rafraîchi.
- `401 Unauthorized` : Refresh token absent ou invalide.
</Accordion>
<Accordion title="POST /auth/logout">
Invalide la session actuelle en détruisant le cookie de session.
**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é.
Invalide la session actuelle.
</Accordion>
</Accordions>
@@ -114,62 +80,16 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
<Accordions>
<Accordion title="GET /users/me">
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 title="GET /users/me/export">
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.
**Réponses :**
- `200 OK` : Archive JSON des données.
- `401 Unauthorized` : Non authentifié.
Contient le profil, les contenus et les favoris.
</Accordion>
<Accordion title="PATCH /users/me">
Met à jour les informations du profil.
**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.
- `displayName` (string)
</Accordion>
<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">
Les données sont définitivement purgées après un délai légal de 30 jours.
</Callout>
**Réponses :**
- `200 OK` : Suppression planifiée.
</Accordion>
<Accordion title="POST /users/me/2fa/setup">
Génère un secret et un QR Code pour la configuration de la 2FA.
**Réponses :**
- `201 Created` :
```json
{
"secret": "JBSWY3DPEHPK3PXP",
"qrCodeDataUrl": "data:image/png;base64,..."
}
```
<Accordion title="Gestion 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.
- `POST /users/me/2fa/disable` : Désactive avec jeton.
</Accordion>
<Accordion title="POST /users/me/2fa/enable">
Active la 2FA après vérification du jeton TOTP.
**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 title="Administration (GET /users/admin)">
Liste tous les utilisateurs. Réservé aux administrateurs.
**Params :** `limit`, `offset`.
</Accordion>
</Accordions>
### 🖼️ Contenus (`/contents`)
<Accordions>
<Accordion title="GET /contents/explore">
Recherche et filtre les contenus. Cet endpoint est mis en cache pendant 1 minute.
<Accordion title="GET /contents/explore | /trends | /recent">
Recherche et filtre les contenus. Ces endpoints sont mis en cache (Redis + Navigateur).
**Query Params :**
- `limit` (number) : Défaut 10.
- `offset` (number) : Défaut 0.
- `sort` : `trend` | `recent`
- `tag` (string) : Filtrer par tag (nom).
- `category` (slug ou uuid) : Filtrer par catégorie.
- `author` (username) : Filtrer par auteur.
- `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.
- `sort` : `trend` | `recent` (uniquement sur `/explore`)
- `tag` (string)
- `category` (slug ou id)
- `author` (username)
- `query` (titre)
- `favoritesOnly` (bool)
</Accordion>
<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) :**
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**.
**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é.
Si l'User-Agent correspond à un robot d'indexation (Googlebot, Twitterbot, etc.), l'API retourne un rendu HTML minimal contenant les méta-tags **OpenGraph** et **Twitter Cards** pour un partage optimal. Pour les autres clients, les données sont retournées en JSON.
</Accordion>
<Accordion 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`
**Champs :**
- `file` (binary) : png, jpeg, webp, webm, gif.
- `type` : `meme` | `gif`
- `title` (string)
- `categoryId` (uuid, optional)
- `tags` (string[], optional)
**Réponses :**
- `201 Created` : Upload réussi et contenu créé.
- `400 Bad Request` : Fichier non supporté ou données invalides.
- `title` : string
- `categoryId`? : uuid
- `tags`? : string[]
</Accordion>
<Accordion title="POST /contents/upload-url">
Génère une URL présignée pour un upload direct vers S3.
**Query Param :**
- `fileName` (string) : 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 title="POST /contents/:id/view | /use">
Incrémente les statistiques de vue ou d'utilisation.
</Accordion>
<Accordion title="DELETE /contents/:id">
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>
</Accordions>
### 📂 Catégories (`/categories`)
### 📂 Catégories, ⭐ Favoris, 🚩 Signalements
<Accordions>
<Accordion title="GET /categories">
Liste toutes les catégories de mèmes disponibles. Cache de 1 heure.
**Réponses :**
- `200 OK` : Liste d'objets catégorie.
<Accordion title="Catégories (/categories)">
- `GET /categories` : Liste toutes les catégories.
- `POST /categories` : Création (Admin uniquement).
</Accordion>
<Accordion title="GET /categories/:id">
Récupère les détails d'une catégorie spécifique.
**Réponses :**
- `200 OK` : Objet catégorie.
- `404 Not Found` : Catégorie non trouvée.
<Accordion title="Favoris (/favorites)">
- `GET /favorites` : Liste les favoris de l'utilisateur.
- `POST /favorites/:contentId` : Ajoute un favori.
- `DELETE /favorites/:contentId` : Retire un favori.
</Accordion>
<Accordion title="POST /categories">
Crée une nouvelle catégorie. **Admin uniquement.**
**Corps de la requête :**
- `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 title="Signalements (/reports)">
- `POST /reports` : Signale un contenu ou un tag.
- `GET /reports` : Liste (Modérateurs).
- `PATCH /reports/:id/status` : Gère le workflow.
</Accordion>
</Accordions>
### ⭐ Favoris (`/favorites`)
### 🔑 Clés API & 🏷️ Tags
<Accordions>
<Accordion title="GET /favorites">
Liste les favoris de l'utilisateur connecté.
**Query Params :**
- `limit` (number) : Défaut 10.
- `offset` (number) : Défaut 0.
**Réponses :**
- `200 OK` : Liste paginée des favoris.
- `401 Unauthorized` : Non authentifié.
<Accordion title="Clés API (/api-keys)">
- `POST /api-keys` : Génère une clé `{ name, expiresAt? }`.
- `GET /api-keys` : Liste les clés actives.
- `DELETE /api-keys/:id` : Révoque une clé.
</Accordion>
<Accordion title="POST /favorites/:contentId">
Ajoute un contenu aux favoris de l'utilisateur.
**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 title="Tags (/tags)">
- `GET /tags` : Recherche de tags populaires ou récents.
**Params :** `query`, `sort`, `limit`.
</Accordion>
</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." />
</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 :
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.
Liste des intégrations tierces.

View File

@@ -35,13 +35,10 @@ erDiagram
string username
string email
string display_name
string avatar_url
string bio
string status
}
CONTENT {
string title
string slug
string type
string storage_key
}
@@ -85,8 +82,6 @@ erDiagram
bytea email
varchar email_hash
varchar display_name
varchar avatar_url
varchar bio
varchar password_hash
user_status status
bytea two_factor_secret
@@ -105,7 +100,6 @@ erDiagram
uuid category_id FK
content_type type
varchar title
varchar slug
varchar storage_key
varchar mime_type
integer file_size
@@ -239,8 +233,6 @@ erDiagram
varchar email_hash "UNIQUE, INDEXED"
varchar username "UNIQUE, NOT NULL"
varchar password_hash "NOT NULL"
varchar avatar_url "NULLABLE"
varchar bio "NULLABLE"
bytea two_factor_secret "ENCRYPTED"
boolean is_two_factor_enabled "DEFAULT false"
timestamp gdpr_accepted_at "NULLABLE"
@@ -249,7 +241,6 @@ erDiagram
contents {
uuid id "DEFAULT gen_random_uuid()"
uuid user_id "REFERENCES users(uuid)"
varchar slug "UNIQUE, NOT NULL"
varchar storage_key "UNIQUE, NOT NULL"
integer file_size "NOT NULL"
timestamp deleted_at "SOFT DELETE"

View File

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

View File

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

View File

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

View File

@@ -1,149 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { CategoryService } from "@/services/category.service";
import type { Category } from "@/types/content";
interface CategoryDialogProps {
category?: Category | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export function CategoryDialog({
category,
open,
onOpenChange,
onSuccess,
}: CategoryDialogProps) {
const [loading, setLoading] = useState(false);
const form = useForm<Partial<Category>>({
defaultValues: {
name: "",
description: "",
iconUrl: "",
},
});
useEffect(() => {
if (category) {
form.reset({
name: category.name,
description: category.description || "",
iconUrl: category.iconUrl || "",
});
} else {
form.reset({
name: "",
description: "",
iconUrl: "",
});
}
}, [category, form]);
const onSubmit = async (values: Partial<Category>) => {
setLoading(true);
try {
if (category) {
await CategoryService.update(category.id, values);
} else {
await CategoryService.create(values);
}
onSuccess();
onOpenChange(false);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{category ? "Modifier la catégorie" : "Créer une catégorie"}
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
rules={{ required: "Le nom est requis" }}
render={({ field }) => (
<FormItem>
<FormLabel>Nom</FormLabel>
<FormControl>
<Input {...field} placeholder="Nom de la catégorie" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} placeholder="Description (optionnel)" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="iconUrl"
render={({ field }) => (
<FormItem>
<FormLabel>URL de l'icône</FormLabel>
<FormControl>
<Input {...field} placeholder="https://..." />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Annuler
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Enregistrement..." : "Enregistrer"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,9 +1,6 @@
"use client";
import { Edit, Plus, Trash2 } from "lucide-react";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
@@ -15,57 +12,24 @@ import {
} from "@/components/ui/table";
import { CategoryService } from "@/services/category.service";
import type { Category } from "@/types/content";
import { CategoryDialog } from "./category-dialog";
export default function AdminCategoriesPage() {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<Category | null>(
null,
);
const fetchCategories = useCallback(() => {
setLoading(true);
useEffect(() => {
CategoryService.getAll()
.then(setCategories)
.catch((err) => console.error(err))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
fetchCategories();
}, [fetchCategories]);
const handleDelete = async (id: string) => {
if (!confirm("Êtes-vous sûr de vouloir supprimer cette catégorie ?")) return;
try {
await CategoryService.remove(id);
setCategories(categories.filter((c) => c.id !== id));
} catch (error) {
console.error(error);
}
};
const handleEdit = (category: Category) => {
setSelectedCategory(category);
setDialogOpen(true);
};
const handleCreate = () => {
setSelectedCategory(null);
setDialogOpen(true);
};
return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">
Catégories ({categories.length})
</h2>
<Button onClick={handleCreate}>
<Plus className="mr-2 h-4 w-4" /> Ajouter une catégorie
</Button>
</div>
<div className="rounded-md border bg-card">
<Table>
@@ -74,7 +38,6 @@ export default function AdminCategoriesPage() {
<TableHead>Nom</TableHead>
<TableHead>Slug</TableHead>
<TableHead>Description</TableHead>
<TableHead className="w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -91,14 +54,11 @@ export default function AdminCategoriesPage() {
<TableCell>
<Skeleton className="h-4 w-[250px]" />
</TableCell>
<TableCell>
<Skeleton className="h-8 w-8 rounded-full" />
</TableCell>
</TableRow>
))
) : categories.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center h-24">
<TableCell colSpan={3} className="text-center h-24">
Aucune catégorie trouvée.
</TableCell>
</TableRow>
@@ -106,55 +66,18 @@ export default function AdminCategoriesPage() {
categories.map((category) => (
<TableRow key={category.id}>
<TableCell className="font-medium whitespace-nowrap">
<div className="flex items-center gap-2">
{category.iconUrl && (
<div className="relative h-6 w-6">
<Image
src={category.iconUrl}
alt=""
fill
className="rounded object-cover"
/>
</div>
)}
{category.name}
</div>
{category.name}
</TableCell>
<TableCell className="whitespace-nowrap">{category.slug}</TableCell>
<TableCell className="text-muted-foreground">
{category.description || "Aucune description"}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(category)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(category.id)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<CategoryDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
category={selectedCategory}
onSuccess={fetchCategories}
/>
</div>
);
}

View File

@@ -1,155 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { CategoryService } from "@/services/category.service";
import { ContentService } from "@/services/content.service";
import type { Category, Content } from "@/types/content";
interface ContentEditDialogProps {
content: Content | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export function ContentEditDialog({
content,
open,
onOpenChange,
onSuccess,
}: ContentEditDialogProps) {
const [loading, setLoading] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
const form = useForm<{ title: string; categoryId: string }>({
defaultValues: {
title: "",
categoryId: "",
},
});
useEffect(() => {
CategoryService.getAll().then(setCategories).catch(console.error);
}, []);
useEffect(() => {
if (content) {
form.reset({
title: content.title,
categoryId: content.categoryId || "none",
});
}
}, [content, form]);
const onSubmit = async (values: { title: string; categoryId: string }) => {
if (!content) return;
setLoading(true);
try {
const data = {
...values,
categoryId: values.categoryId === "none" ? null : values.categoryId,
};
await ContentService.updateAdmin(content.id, data);
onSuccess();
onOpenChange(false);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Modifier le contenu</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="title"
rules={{ required: "Le titre est requis" }}
render={({ field }) => (
<FormItem>
<FormLabel>Titre</FormLabel>
<FormControl>
<Input {...field} placeholder="Titre du contenu" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormItem>
<FormLabel>Catégorie</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Sélectionner une catégorie" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">Sans catégorie</SelectItem>
{categories.map((cat) => (
<SelectItem key={cat.id} value={cat.id}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Annuler
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Enregistrement..." : "Enregistrer"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -2,15 +2,8 @@
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import {
Download,
Edit,
Eye,
Image as ImageIcon,
Trash2,
Video,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Download, Eye, Image as ImageIcon, Trash2, Video } from "lucide-react";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -24,17 +17,13 @@ import {
} from "@/components/ui/table";
import { ContentService } from "@/services/content.service";
import type { Content } from "@/types/content";
import { ContentEditDialog } from "./content-edit-dialog";
export default function AdminContentsPage() {
const [contents, setContents] = useState<Content[]>([]);
const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0);
const [selectedContent, setSelectedContent] = useState<Content | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const fetchContents = useCallback(() => {
setLoading(true);
useEffect(() => {
ContentService.getExplore({ limit: 20 })
.then((res) => {
setContents(res.data);
@@ -44,10 +33,6 @@ export default function AdminContentsPage() {
.finally(() => setLoading(false));
}, []);
useEffect(() => {
fetchContents();
}, [fetchContents]);
const handleDelete = async (id: string) => {
if (!confirm("Êtes-vous sûr de vouloir supprimer ce contenu ?")) return;
@@ -60,11 +45,6 @@ export default function AdminContentsPage() {
}
};
const handleEdit = (content: Content) => {
setSelectedContent(content);
setDialogOpen(true);
};
return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between">
@@ -81,7 +61,7 @@ export default function AdminContentsPage() {
<TableHead>Auteur</TableHead>
<TableHead>Stats</TableHead>
<TableHead>Date</TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -104,14 +84,11 @@ export default function AdminContentsPage() {
<TableCell>
<Skeleton className="h-4 w-[100px]" />
</TableCell>
<TableCell>
<Skeleton className="h-8 w-8 rounded-full" />
</TableCell>
</TableRow>
))
) : contents.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center h-24">
<TableCell colSpan={5} className="text-center h-24">
Aucun contenu trouvé.
</TableCell>
</TableRow>
@@ -121,7 +98,7 @@ export default function AdminContentsPage() {
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded bg-muted">
{content.mimeType.startsWith("image/") ? (
{content.type === "image" ? (
<ImageIcon className="h-5 w-5 text-muted-foreground" />
) : (
<Video className="h-5 w-5 text-muted-foreground" />
@@ -155,23 +132,14 @@ export default function AdminContentsPage() {
{format(new Date(content.createdAt), "dd/MM/yyyy", { locale: fr })}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(content)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(content.id)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(content.id)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
@@ -179,12 +147,6 @@ export default function AdminContentsPage() {
</TableBody>
</Table>
</div>
<ContentEditDialog
content={selectedContent}
open={dialogOpen}
onOpenChange={setDialogOpen}
onSuccess={fetchContents}
/>
</div>
);
}

View File

@@ -2,8 +2,8 @@
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Edit, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -17,17 +17,13 @@ import {
} from "@/components/ui/table";
import { UserService } from "@/services/user.service";
import type { User } from "@/types/user";
import { UserEditDialog } from "./user-edit-dialog";
export default function AdminUsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const fetchUsers = useCallback(() => {
setLoading(true);
useEffect(() => {
UserService.getUsersAdmin()
.then((res) => {
setUsers(res.data);
@@ -39,10 +35,6 @@ export default function AdminUsersPage() {
.finally(() => setLoading(false));
}, []);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const handleDelete = async (uuid: string) => {
if (
!confirm(
@@ -60,11 +52,6 @@ export default function AdminUsersPage() {
}
};
const handleEdit = (user: User) => {
setSelectedUser(user);
setDialogOpen(true);
};
return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between">
@@ -81,7 +68,7 @@ export default function AdminUsersPage() {
<TableHead>Rôle</TableHead>
<TableHead>Status</TableHead>
<TableHead>Date d'inscription</TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -104,14 +91,11 @@ export default function AdminUsersPage() {
<TableCell>
<Skeleton className="h-4 w-[100px]" />
</TableCell>
<TableCell>
<Skeleton className="h-8 w-8 rounded-full" />
</TableCell>
</TableRow>
))
) : users.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center h-24">
<TableCell colSpan={5} className="text-center h-24">
Aucun utilisateur trouvé.
</TableCell>
</TableRow>
@@ -129,15 +113,7 @@ export default function AdminUsersPage() {
</Badge>
</TableCell>
<TableCell>
<Badge
variant={
user.status === "active"
? "success"
: user.status === "suspended"
? "destructive"
: "secondary"
}
>
<Badge variant={user.status === "active" ? "success" : "destructive"}>
{user.status}
</Badge>
</TableCell>
@@ -145,19 +121,14 @@ export default function AdminUsersPage() {
{format(new Date(user.createdAt), "PPP", { locale: fr })}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => handleEdit(user)}>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(user.uuid)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(user.uuid)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
@@ -165,12 +136,6 @@ export default function AdminUsersPage() {
</TableBody>
</Table>
</div>
<UserEditDialog
user={selectedUser}
open={dialogOpen}
onOpenChange={setDialogOpen}
onSuccess={fetchUsers}
/>
</div>
);
}

View File

@@ -1,153 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { UserService } from "@/services/user.service";
import type { User } from "@/types/user";
interface UserEditDialogProps {
user: User | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export function UserEditDialog({
user,
open,
onOpenChange,
onSuccess,
}: UserEditDialogProps) {
const [loading, setLoading] = useState(false);
const form = useForm<Partial<User>>({
defaultValues: {
role: "user",
status: "active",
},
});
useEffect(() => {
if (user) {
form.reset({
role: user.role || "user",
status: user.status || "active",
});
}
}, [user, form]);
const onSubmit = async (values: Partial<User>) => {
if (!user) return;
setLoading(true);
try {
await UserService.updateAdmin(user.uuid, values);
onSuccess();
onOpenChange(false);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Modifier l'utilisateur @{user?.username}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Rôle</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Sélectionner un rôle" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="user">Utilisateur</SelectItem>
<SelectItem value="moderator">Modérateur</SelectItem>
<SelectItem value="admin">Administrateur</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Statut</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Sélectionner un statut" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="active">Actif</SelectItem>
<SelectItem value="suspended">Suspendu</SelectItem>
<SelectItem value="pending">En attente</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Annuler
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Enregistrement..." : "Enregistrer"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

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

View File

@@ -1,16 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Laptop,
Loader2,
Moon,
Palette,
Save,
Sun,
User as UserIcon,
} from "lucide-react";
import { useTheme } from "next-themes";
import { Loader2, Save, User as UserIcon } from "lucide-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -33,8 +24,6 @@ import {
FormMessage,
} from "@/components/ui/form";
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 { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/providers/auth-provider";
@@ -48,14 +37,8 @@ const settingsSchema = z.object({
type SettingsFormValues = z.infer<typeof settingsSchema>;
export default function SettingsPage() {
const { theme, setTheme } = useTheme();
const { user, isLoading, refreshUser } = useAuth();
const [isSaving, setIsSaving] = React.useState(false);
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
const form = useForm<SettingsFormValues>({
resolver: zodResolver(settingsSchema),
@@ -202,55 +185,6 @@ export default function SettingsPage() {
</Form>
</CardContent>
</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>
);
}

View File

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

View File

@@ -19,7 +19,6 @@ import {
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import * as React from "react";
import { ModeToggle } from "@/components/mode-toggle";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Collapsible,
@@ -287,14 +286,6 @@ export function AppSidebar() {
</SidebarMenuButton>
</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>
<SidebarMenuButton asChild tooltip="Aide">
<Link href="/help">

View File

@@ -1,6 +1,6 @@
"use client";
import { Edit, Eye, Heart, MoreHorizontal, Share2, Trash2 } from "lucide-react";
import { Eye, Heart, MoreHorizontal, Share2 } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -15,31 +15,20 @@ import {
CardFooter,
CardHeader,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useAuth } from "@/providers/auth-provider";
import { ContentService } from "@/services/content.service";
import { FavoriteService } from "@/services/favorite.service";
import type { Content } from "@/types/content";
import { UserContentEditDialog } from "./user-content-edit-dialog";
interface ContentCardProps {
content: Content;
onUpdate?: () => void;
}
export function ContentCard({ content, onUpdate }: ContentCardProps) {
const { isAuthenticated, user } = useAuth();
export function ContentCard({ content }: ContentCardProps) {
const { isAuthenticated } = useAuth();
const router = useRouter();
const [isLiked, setIsLiked] = React.useState(content.isLiked || false);
const [likesCount, setLikesCount] = React.useState(content.favoritesCount);
const [editDialogOpen, setEditDialogOpen] = React.useState(false);
const isAuthor = user?.uuid === content.authorId;
React.useEffect(() => {
setIsLiked(content.isLiked || false);
@@ -82,145 +71,95 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
}
};
const handleDelete = async () => {
if (!confirm("Êtes-vous sûr de vouloir supprimer ce mème ?")) return;
try {
await ContentService.remove(content.id);
toast.success("Mème supprimé !");
onUpdate?.();
} catch (_error) {
toast.error("Erreur lors de la suppression.");
}
};
return (
<>
<Card className="overflow-hidden border-none shadow-sm hover:shadow-md transition-shadow">
<CardHeader className="p-4 flex flex-row items-center space-y-0 gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={content.author.avatarUrl} />
<AvatarFallback>
{content.author.username[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<Link
href={`/user/${content.author.username}`}
className="text-sm font-semibold hover:underline"
>
{content.author.displayName || content.author.username}
</Link>
<span className="text-xs text-muted-foreground">
{new Date(content.createdAt).toLocaleDateString("fr-FR")}
</span>
</div>
<div className="ml-auto flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isAuthor && (
<>
<DropdownMenuItem onClick={() => setEditDialogOpen(true)}>
<Edit className="h-4 w-4 mr-2" />
Modifier
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Supprimer
</DropdownMenuItem>
</>
)}
<DropdownMenuItem onClick={() => toast.success("Lien copié !")}>
<Share2 className="h-4 w-4 mr-2" />
Partager
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="p-0 relative bg-zinc-200 dark:bg-zinc-900 aspect-square flex items-center justify-center">
<Link href={`/meme/${content.slug}`} className="w-full h-full relative">
{content.mimeType.startsWith("image/") ? (
<Image
src={content.url}
alt={content.title}
fill
className="object-contain"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
) : (
<video
src={content.url}
controls={false}
autoPlay
muted
loop
className="w-full h-full object-contain"
/>
)}
<Card className="overflow-hidden border-none shadow-sm hover:shadow-md transition-shadow">
<CardHeader className="p-4 flex flex-row items-center space-y-0 gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={content.author.avatarUrl} />
<AvatarFallback>{content.author.username[0].toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<Link
href={`/user/${content.author.username}`}
className="text-sm font-semibold hover:underline"
>
{content.author.displayName || content.author.username}
</Link>
</CardContent>
<CardFooter className="p-4 flex flex-col gap-4">
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className={`gap-1.5 h-8 ${isLiked ? "text-red-500 hover:text-red-600" : ""}`}
onClick={handleLike}
>
<Heart className={`h-4 w-4 ${isLiked ? "fill-current" : ""}`} />
<span className="text-xs">{likesCount}</span>
</Button>
<Button variant="ghost" size="sm" className="gap-1.5 h-8">
<Eye className="h-4 w-4" />
<span className="text-xs">{content.views}</span>
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Share2 className="h-4 w-4" />
</Button>
</div>
<span className="text-xs text-muted-foreground">
{new Date(content.createdAt).toLocaleDateString("fr-FR")}
</span>
</div>
<Button variant="ghost" size="icon" className="ml-auto h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</CardHeader>
<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">
{content.type === "image" ? (
<Image
src={content.url}
alt={content.title}
fill
className="object-contain"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
) : (
<video
src={content.url}
controls={false}
autoPlay
muted
loop
className="w-full h-full object-contain"
/>
)}
</Link>
</CardContent>
<CardFooter className="p-4 flex flex-col gap-4">
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
variant="secondary"
className="text-xs h-8"
onClick={handleUse}
className={`gap-1.5 h-8 ${isLiked ? "text-red-500 hover:text-red-600" : ""}`}
onClick={handleLike}
>
Utiliser
<Heart className={`h-4 w-4 ${isLiked ? "fill-current" : ""}`} />
<span className="text-xs">{likesCount}</span>
</Button>
<Button variant="ghost" size="sm" className="gap-1.5 h-8">
<Eye className="h-4 w-4" />
<span className="text-xs">{content.views}</span>
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Share2 className="h-4 w-4" />
</Button>
</div>
<Button
size="sm"
variant="secondary"
className="text-xs h-8"
onClick={handleUse}
>
Utiliser
</Button>
</div>
<div className="w-full space-y-2">
<h3 className="font-medium text-sm line-clamp-2">{content.title}</h3>
<div className="flex flex-wrap gap-1">
{content.tags.slice(0, 3).map((tag, _i) => (
<Badge
key={typeof tag === "string" ? tag : tag.id}
variant="secondary"
className="text-[10px] py-0 px-1.5"
>
#{typeof tag === "string" ? tag : tag.name}
</Badge>
))}
</div>
<div className="w-full space-y-2">
<h3 className="font-medium text-sm line-clamp-2">{content.title}</h3>
<div className="flex flex-wrap gap-1">
{content.tags.slice(0, 3).map((tag, _i) => (
<Badge
key={typeof tag === "string" ? tag : tag.id}
variant="secondary"
className="text-[10px] py-0 px-1.5"
>
#{typeof tag === "string" ? tag : tag.name}
</Badge>
))}
</div>
</CardFooter>
</Card>
<UserContentEditDialog
content={content}
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
onSuccess={() => onUpdate?.()}
/>
</>
</div>
</CardFooter>
</Card>
);
}

View File

@@ -20,27 +20,6 @@ export function ContentList({ fetchFn, title }: ContentListProps) {
const [offset, setOffset] = React.useState(0);
const [hasMore, setHasMore] = React.useState(true);
const fetchInitial = React.useCallback(async () => {
setLoading(true);
try {
const response = await fetchFn({
limit: 10,
offset: 0,
});
setContents(response.data);
setOffset(0);
setHasMore(response.data.length === 10);
} catch (error) {
console.error("Failed to fetch contents:", error);
} finally {
setLoading(false);
}
}, [fetchFn]);
React.useEffect(() => {
fetchInitial();
}, [fetchInitial]);
const loadMore = React.useCallback(async () => {
if (!hasMore || loading) return;
@@ -67,12 +46,32 @@ export function ContentList({ fetchFn, title }: ContentListProps) {
onLoadMore: loadMore,
});
React.useEffect(() => {
const fetchInitial = async () => {
setLoading(true);
try {
const response = await fetchFn({
limit: 10,
offset: 0,
});
setContents(response.data);
setHasMore(response.data.length === 10);
} catch (error) {
console.error("Failed to fetch contents:", error);
} finally {
setLoading(false);
}
};
fetchInitial();
}, [fetchFn]);
return (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-8">
{title && <h1 className="text-2xl font-bold">{title}</h1>}
<div className="flex flex-col gap-6">
{contents.map((content) => (
<ContentCard key={content.id} content={content} onUpdate={fetchInitial} />
<ContentCard key={content.id} content={content} />
))}
</div>

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

@@ -1,158 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { CategoryService } from "@/services/category.service";
import { ContentService } from "@/services/content.service";
import type { Category, Content } from "@/types/content";
interface UserContentEditDialogProps {
content: Content | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export function UserContentEditDialog({
content,
open,
onOpenChange,
onSuccess,
}: UserContentEditDialogProps) {
const [loading, setLoading] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
const form = useForm<{ title: string; categoryId: string }>({
defaultValues: {
title: "",
categoryId: "",
},
});
useEffect(() => {
CategoryService.getAll().then(setCategories).catch(console.error);
}, []);
useEffect(() => {
if (content) {
form.reset({
title: content.title,
categoryId: content.categoryId || "none",
});
}
}, [content, form]);
const onSubmit = async (values: { title: string; categoryId: string }) => {
if (!content) return;
setLoading(true);
try {
const data = {
...values,
categoryId: values.categoryId === "none" ? null : values.categoryId,
};
await ContentService.update(content.id, data);
toast.success("Mème mis à jour !");
onSuccess();
onOpenChange(false);
} catch (error) {
console.error(error);
toast.error("Erreur lors de la mise à jour.");
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Modifier mon mème</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="title"
rules={{ required: "Le titre est requis" }}
render={({ field }) => (
<FormItem>
<FormLabel>Titre</FormLabel>
<FormControl>
<Input {...field} placeholder="Titre du mème" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormItem>
<FormLabel>Catégorie</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Sélectionner une catégorie" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">Sans catégorie</SelectItem>
{categories.map((cat) => (
<SelectItem key={cat.id} value={cat.id}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Annuler
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Enregistrement..." : "Enregistrer"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

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

View File

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

@@ -11,18 +11,4 @@ export const CategoryService = {
const { data } = await api.get<Category>(`/categories/${id}`);
return data;
},
async create(category: Partial<Category>): Promise<Category> {
const { data } = await api.post<Category>("/categories", category);
return data;
},
async update(id: string, category: Partial<Category>): Promise<Category> {
const { data } = await api.patch<Category>(`/categories/${id}`, category);
return data;
},
async remove(id: string): Promise<void> {
await api.delete(`/categories/${id}`);
},
};

View File

@@ -65,18 +65,4 @@ export const ContentService = {
async removeAdmin(id: string): Promise<void> {
await api.delete(`/contents/${id}/admin`);
},
async update(id: string, update: Partial<Content>): Promise<Content> {
const { data } = await api.patch<Content>(`/contents/${id}`, update);
return data;
},
async remove(id: string): Promise<void> {
await api.delete(`/contents/${id}`);
},
async updateAdmin(id: string, update: Partial<Content>): Promise<Content> {
const { data } = await api.patch<Content>(`/contents/${id}/admin`, update);
return data;
},
};

View File

@@ -34,11 +34,6 @@ export const UserService = {
await api.delete(`/users/${uuid}`);
},
async updateAdmin(uuid: string, update: Partial<User>): Promise<User> {
const { data } = await api.patch<User>(`/users/admin/${uuid}`, update);
return data;
},
async updateAvatar(file: File): Promise<User> {
const formData = new FormData();
formData.append("file", file);

View File

@@ -7,7 +7,7 @@ export interface Content {
description?: string;
url: string;
thumbnailUrl?: string;
type: "meme" | "gif";
type: "image" | "video";
mimeType: string;
size: number;
width?: number;
@@ -18,7 +18,6 @@ export interface Content {
favoritesCount: number;
isLiked?: boolean;
tags: (string | Tag)[];
categoryId?: string | null;
category?: Category;
authorId: string;
author: User;
@@ -37,7 +36,6 @@ export interface Category {
name: string;
slug: string;
description?: string;
iconUrl?: string;
}
export interface PaginatedResponse<T> {

View File

@@ -6,7 +6,7 @@ export interface User {
displayName?: string;
avatarUrl?: string;
bio?: string;
role?: "user" | "admin" | "moderator";
role?: "user" | "admin";
status?: "active" | "verification" | "suspended" | "pending" | "deleted";
createdAt: string;
}

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",
"version": "1.4.0",
"version": "0.0.1",
"description": "",
"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:front": "pnpm run -F @memegoat/frontend build",
"build:back": "pnpm run -F @memegoat/backend build",
"build:docs": "pnpm run -F @memegoat/documentation build",
"lint": "pnpm run lint:back && pnpm run lint:front && pnpm run lint:docs",
"lint: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:fix": "pnpm run -F @memegoat/backend lint:write",
"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:fix": "pnpm run -F @memegoat/documentation lint:write",
"test": "pnpm run test:back && pnpm run test:front",
"test:back": "pnpm run -F @memegoat/backend test",
"test:front": "pnpm run -F @memegoat/frontend test",

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()