Compare commits

...

33 Commits

Author SHA1 Message Date
Mathis HERRIOT
cc2823db7d refactor: organize imports and enhance formatting across backend files
Some checks failed
Backend Tests / test (push) Successful in 9m38s
Lint / lint (push) Failing after 4m59s
Optimized import order, applied consistent formatting, and improved readability in various modules, including `contents`, `media`, and `auth` services.
2026-01-08 15:33:55 +01:00
Mathis HERRIOT
6254c136d1 feat: enable standalone output mode in Next.js configuration 2026-01-08 15:32:55 +01:00
Mathis HERRIOT
3828f170e2 feat: add Dockerfile for frontend service
Introduce a multi-stage Dockerfile for the frontend service to enable efficient builds and an optimized runtime using Node.js 22.
2026-01-08 15:32:46 +01:00
Mathis HERRIOT
ec771eb074 feat: revamp documentation pages with new components, layout, and visuals
Introduced enhanced MDX components (cards, callouts, tabs, accordions, steps) for better content presentation. Redesigned the homepage with new sections highlighting features, tech stack, and quick access links. Updated the global CSS to use Catppuccin theme. Added branded visuals like the Memegoat logo and SVG. Improved metadata, localization (French), and search functionality.
2026-01-08 15:32:13 +01:00
Mathis HERRIOT
77263aead9 chore: update .env.example with media size limits configuration 2026-01-08 15:31:54 +01:00
Mathis HERRIOT
ab74dc3b30 chore: update pnpm-lock.yaml with new dependency resolutions
Added new dependencies for validation, caching, security, and media processing. Updated existing resolutions to synchronize with the package changes and introduced support for new modules like @nestjs/cache-manager and @nestjs/throttler.
2026-01-08 15:31:45 +01:00
Mathis HERRIOT
acd53eff6a docs: add secure media processing details to README
Include information about antivirus scanning (ClamAV) and high-performance transcoding (WebP, WebM, AVIF, AV1) under the media processing section.
2026-01-08 15:31:29 +01:00
Mathis HERRIOT
91e23c2c02 chore: add .dockerignore to exclude unnecessary files from Docker context 2026-01-08 15:30:56 +01:00
Mathis HERRIOT
f508e8ee6d refactor: rename package scope to @memegoat in documentation/package.json 2026-01-08 15:30:39 +01:00
Mathis HERRIOT
3c02bd6023 feat: configure standalone output mode in next.js 2026-01-08 15:30:29 +01:00
Mathis HERRIOT
6e823743fc feat: add Dockerfile for documentation service
Introduce a multi-stage Dockerfile for the documentation service to enable efficient builds and optimized runtime with Node.js 22.
2026-01-08 15:30:20 +01:00
Mathis HERRIOT
99a350aa05 docs: overhaul and expand technical documentation
Revamped the documentation structure and content to enhance usability and organization. Added detailed sections on architecture, pipeline, security, API reference, deployment steps, compliance, and supported modules. Introduced new visuals like cards, accordions, and callouts for improved readability and navigation.
2026-01-08 15:29:56 +01:00
Mathis HERRIOT
8b51b84d44 feat: add Dockerfile for backend service
Introduced a multi-stage Dockerfile for the backend, enabling streamlined builds and optimized runtime image with Node.js 22.
2026-01-08 15:29:37 +01:00
Mathis HERRIOT
0af6f6b52a feat: update dependencies and scripts in package.json
Added new dependencies for caching, security, media processing, and validation. Updated scripts and included the `dist` folder for build output. Refined devDependencies to support new features and typings.
2026-01-08 15:28:38 +01:00
Mathis HERRIOT
382e39ebd0 feat: update biome.json with JavaScript parser configuration and linter rule adjustments
Added support for `unsafeParameterDecoratorsEnabled` in JavaScript parser configuration. Modified linter rules to include a `correctness` section disabling `useHookAtTopLevel`. Simplified domain-specific linter configurations.
2026-01-08 15:28:28 +01:00
Mathis HERRIOT
65b7cba6b1 feat: enhance bootstrap with Sentry, security middleware, and global configurations
Integrated Sentry for error monitoring and profiling. Added security improvements using Helmet and CORS. Implemented global validation pipes and exception filters for consistent request handling. Dynamically configured app PORT and logging for startup information.
2026-01-08 15:28:16 +01:00
Mathis HERRIOT
f7d85108e1 feat: add HealthController with database connection check
Introduced a HealthController to verify application status. Includes an endpoint to check database connectivity and returns health status with a timestamp.
2026-01-08 15:27:48 +01:00
Mathis HERRIOT
d5775a821e feat: integrate multiple modules, caching, throttling, and scheduling into AppModule
Enhanced AppModule by adding support for caching (Redis), scheduling, throttling, and multiple feature modules including AuthModule, CategoriesModule, ContentsModule, FavoritesModule, ReportsModule, TagsModule, and more. Improved environment validation and health check controller setup.
2026-01-08 15:27:36 +01:00
Mathis HERRIOT
add7cab7df feat: implement UsersModule with service, controller, and DTOs
Added UsersModule to manage user-related operations. Includes UsersService for CRUD operations, consent updates, and 2FA handling. Implemented UsersController with endpoints for public profiles, account management, and admin user listing. Integrated with CryptoService and database schemas.
2026-01-08 15:27:20 +01:00
Mathis HERRIOT
da5f18bf92 feat: implement TagsModule with service, controller, and endpoints
Added TagsModule to manage tags, including a TagsService for querying and sorting by popularity or recency. Created TagsController with endpoint to retrieve paginated and searchable tag data. Integrated with database and relevant schemas.
2026-01-08 15:27:11 +01:00
Mathis HERRIOT
a0836c8392 feat: add SessionsModule with service for session management
Implemented SessionsModule and SessionsService to manage user sessions. Includes methods for session creation, refresh token rotation, and session revocation. Integrated with database and CryptoService for secure token handling.
2026-01-08 15:27:02 +01:00
Mathis HERRIOT
9963046e41 feat: add method to generate presigned S3 upload URLs
Implemented a `getUploadUrl` method in S3 service to generate presigned URLs for uploading files. Includes support for custom bucket names and expiry times, with error handling and logging.
2026-01-08 15:26:50 +01:00
Mathis HERRIOT
dde1bf522f feat: implement ReportsModule with service, controller, and endpoints
Added ReportsModule to manage user reports. Includes service methods for creating, retrieving, and updating report statuses, as well as controller endpoints for handling these operations. Integrated with authentication, role-based access control, and database logic.
2026-01-08 15:26:39 +01:00
Mathis HERRIOT
dd875fe1ea feat: add MediaModule with service for virus scanning and media processing
Introduced MediaModule with MediaService to handle antivirus scanning using ClamAV and media file processing for images (webp/avif) and videos (webm/av1). Includes media-related interfaces and module exports for broader application integration.
2026-01-08 15:26:25 +01:00
Mathis HERRIOT
92ea36545a feat: add FavoritesModule with service, controller, and CRUD endpoints
Implemented FavoritesModule to manage user favorites. Includes service methods for adding, removing, and listing favorites, along with appropriate database integrations and API endpoints.
2026-01-08 15:26:05 +01:00
Mathis HERRIOT
912394477b feat: add categories and favorites schemas with integrations
Added `categories` and `favorites` database schemas with full type inference support. Integrated categories into `content` schema with new properties (`categoryId`, `slug`, `views`, and `usageCount`). Updated `tags` schema to include `userId` with reference to `users`. Exported new schemas in index for broader usage.
2026-01-08 15:25:51 +01:00
Mathis HERRIOT
fe309bc1e3 feat: add hashing methods for email and IP in CryptoService for blind indexing
Introduced `hashEmail` and `hashIp` methods to enable searching on encrypted data. Added support to retrieve PGP encryption key from configuration.
2026-01-08 15:25:40 +01:00
Mathis HERRIOT
342e9b99da feat: implement ContentsModule with controllers, services, and DTOs
Added a new ContentsModule to handle content creation, upload, and management. Includes controller endpoints for CRUD operations, content exploration, and tagging. Integrated caching, file processing, S3 storage, and database logic.
2026-01-08 15:25:28 +01:00
Mathis HERRIOT
e210f1f95f feat: add env schema for environment variable validation
Introduced `env.schema.ts` for structured validation of environment variables using Zod. Includes defaults and validations for database, S3, security, mail, Redis, and session configurations.
2026-01-08 15:25:16 +01:00
Mathis HERRIOT
2218768adb feat: add CommonModule with PurgeService and global exception filter
Introduced CommonModule to centralize shared functionality. Added PurgeService for automated database cleanup and a global exception filter for unified error handling.
2026-01-08 15:25:04 +01:00
Mathis HERRIOT
705f1ad6e0 feat: add CategoriesModule with CRUD operations
Implemented CategoriesModule with controller, service, and DTOs for managing categories. Includes endpoints for creation, retrieval, updating, and deletion, integrated with database logic.
2026-01-08 15:24:48 +01:00
Mathis HERRIOT
42805e371e feat: implement AuthModule with authentication and RBAC features
Added AuthModule with services, controllers, and guards for authentication. Implements session management, role-based access control, 2FA, and DTOs for user login, registration, and token refresh.
2026-01-08 15:24:40 +01:00
Mathis HERRIOT
9406ed9350 feat: implement ApiKeysModule with services, controller, and CRUD operations
Added a dedicated ApiKeysModule to manage API keys. Includes functionality to create, list, revoke, and validate keys, leveraging cryptographic hashing and database support. Integrated with authentication guards for security.
2026-01-08 15:24:23 +01:00
91 changed files with 5011 additions and 178 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
.git
.gitignore
.next
dist
.env
*.log

View File

@@ -34,3 +34,7 @@ MAIL_USER=user
MAIL_PASS=password
MAIL_FROM=noreply@memegoat.fr
DOMAIN_NAME=memegoat.fr
# Media Limits (in KB)
MAX_IMAGE_SIZE_KB=512
MAX_GIF_SIZE_KB=1024

View File

@@ -76,6 +76,7 @@ Pour utiliser l'API, vous pouvez générer des clés API sécurisées directemen
- **RGPD by Design** : Mécanismes de Soft Delete, purge automatique et hachage des IPs.
- **Multi-Authentification** : Support des sessions JWT, des clés API et de la double authentification (2FA).
- **Gestion de Contenu** : Support des mèmes et GIFs avec système de tags et signalements.
- **Traitement Médias Sécurisé** : Scan antivirus (ClamAV) systématique et transcodage haute performance (WebP, WebM, AVIF, AV1).
## Contribution

17
backend/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM node:22-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS build
WORKDIR /usr/src/app
COPY . .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run --filter @memegoat/backend build
RUN pnpm deploy --filter=@memegoat/backend --prod /app
FROM base AS runtime
WORKDIR /app
COPY --from=build /app .
EXPOSE 3000
CMD [ "node", "dist/main" ]

View File

@@ -14,6 +14,11 @@
"indentStyle": "tab",
"indentWidth": 1
},
"javascript": {
"parser": {
"unsafeParameterDecoratorsEnabled": true
}
},
"linter": {
"enabled": true,
"rules": {
@@ -23,11 +28,10 @@
},
"style": {
"useImportType": "off"
},
"correctness": {
"useHookAtTopLevel": "off"
}
},
"domains": {
"next": "recommended",
"react": "recommended"
}
},
"assist": {

View File

@@ -5,6 +5,9 @@
"author": "",
"private": true,
"license": "UNLICENSED",
"files": [
"dist"
],
"scripts": {
"build": "nest build",
"lint": "biome check",
@@ -25,31 +28,55 @@
},
"dependencies": {
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/cache-manager": "^3.1.0",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.0",
"@nestjs/throttler": "^6.5.0",
"@noble/post-quantum": "^0.5.4",
"@node-rs/argon2": "^2.0.2",
"@sentry/nestjs": "^10.32.1",
"@sentry/profiling-node": "^10.32.1",
"cache-manager": "^7.2.7",
"cache-manager-redis-yet": "^5.1.5",
"clamscan": "^2.4.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1",
"fluent-ffmpeg": "^2.1.3",
"helmet": "^8.1.0",
"iron-session": "^8.0.4",
"jose": "^6.1.3",
"minio": "^8.0.6",
"nodemailer": "^7.0.12",
"otplib": "^12.0.1",
"pg": "^8.16.3",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"sharp": "^0.34.5",
"uuid": "^13.0.0",
"zod": "^4.3.5"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/jest": "^30.0.0",
"@types/multer": "^2.0.0",
"@types/node": "^22.10.7",
"@types/nodemailer": "^7.0.4",
"@types/pg": "^8.16.0",
"@types/qrcode": "^1.5.6",
"@types/sharp": "^0.32.0",
"@types/supertest": "^6.0.2",
"@types/uuid": "^11.0.0",
"drizzle-kit": "^0.31.8",
"globals": "^16.0.0",
"jest": "^30.0.0",

View File

@@ -0,0 +1,42 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Req,
UseGuards,
} from "@nestjs/common";
import { AuthGuard } from "../auth/guards/auth.guard";
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { ApiKeysService } from "./api-keys.service";
@Controller("api-keys")
@UseGuards(AuthGuard)
export class ApiKeysController {
constructor(private readonly apiKeysService: ApiKeysService) {}
@Post()
create(
@Req() req: AuthenticatedRequest,
@Body("name") name: string,
@Body("expiresAt") expiresAt?: string,
) {
return this.apiKeysService.create(
req.user.sub,
name,
expiresAt ? new Date(expiresAt) : undefined,
);
}
@Get()
findAll(@Req() req: AuthenticatedRequest) {
return this.apiKeysService.findAll(req.user.sub);
}
@Delete(":id")
revoke(@Req() req: AuthenticatedRequest, @Param("id") id: string) {
return this.apiKeysService.revoke(req.user.sub, id);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { ApiKeysController } from "./api-keys.controller";
import { ApiKeysService } from "./api-keys.service";
@Module({
imports: [DatabaseModule, AuthModule, CryptoModule],
controllers: [ApiKeysController],
providers: [ApiKeysService],
exports: [ApiKeysService],
})
export class ApiKeysModule {}

View File

@@ -0,0 +1,79 @@
import { createHash, randomBytes } from "node:crypto";
import { Injectable } from "@nestjs/common";
import { and, eq } from "drizzle-orm";
import { DatabaseService } from "../database/database.service";
import { apiKeys } from "../database/schemas";
@Injectable()
export class ApiKeysService {
constructor(private readonly databaseService: DatabaseService) {}
async create(userId: string, name: string, expiresAt?: Date) {
const prefix = "mg_live_";
const randomPart = randomBytes(24).toString("hex");
const key = `${prefix}${randomPart}`;
const keyHash = createHash("sha256").update(key).digest("hex");
await this.databaseService.db.insert(apiKeys).values({
userId,
name,
prefix: prefix.substring(0, 8),
keyHash,
expiresAt,
});
return {
name,
key, // Retourné une seule fois à la création
expiresAt,
};
}
async findAll(userId: string) {
return await this.databaseService.db
.select({
id: apiKeys.id,
name: apiKeys.name,
prefix: apiKeys.prefix,
isActive: apiKeys.isActive,
lastUsedAt: apiKeys.lastUsedAt,
expiresAt: apiKeys.expiresAt,
createdAt: apiKeys.createdAt,
})
.from(apiKeys)
.where(eq(apiKeys.userId, userId));
}
async revoke(userId: string, keyId: string) {
return await this.databaseService.db
.update(apiKeys)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, userId)))
.returning();
}
async validateKey(key: string) {
const keyHash = createHash("sha256").update(key).digest("hex");
const [apiKey] = await this.databaseService.db
.select()
.from(apiKeys)
.where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)))
.limit(1);
if (!apiKey) return null;
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
return null;
}
// Update last used at
await this.databaseService.db
.update(apiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(apiKeys.id, apiKey.id));
return apiKey;
}
}

View File

@@ -1,23 +1,74 @@
import { CacheModule } from "@nestjs/cache-manager";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { ScheduleModule } from "@nestjs/schedule";
import { ThrottlerModule } from "@nestjs/throttler";
import { redisStore } from "cache-manager-redis-yet";
import { ApiKeysModule } from "./api-keys/api-keys.module";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { AuthModule } from "./auth/auth.module";
import { CategoriesModule } from "./categories/categories.module";
import { CommonModule } from "./common/common.module";
import { validateEnv } from "./config/env.schema";
import { ContentsModule } from "./contents/contents.module";
import { CryptoModule } from "./crypto/crypto.module";
import { DatabaseModule } from "./database/database.module";
import { FavoritesModule } from "./favorites/favorites.module";
import { HealthController } from "./health.controller";
import { MailModule } from "./mail/mail.module";
import { MediaModule } from "./media/media.module";
import { ReportsModule } from "./reports/reports.module";
import { S3Module } from "./s3/s3.module";
import { SessionsModule } from "./sessions/sessions.module";
import { TagsModule } from "./tags/tags.module";
import { UsersModule } from "./users/users.module";
@Module({
imports: [
DatabaseModule,
CryptoModule,
CommonModule,
S3Module,
MailModule,
UsersModule,
AuthModule,
CategoriesModule,
ContentsModule,
FavoritesModule,
TagsModule,
MediaModule,
SessionsModule,
ReportsModule,
ApiKeysModule,
ScheduleModule.forRoot(),
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => [
{
ttl: 60000,
limit: config.get("NODE_ENV") === "production" ? 100 : 1000,
},
],
}),
ConfigModule.forRoot({
isGlobal: true,
validate: validateEnv,
}),
CacheModule.registerAsync({
isGlobal: true,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (config: ConfigService) => ({
store: await redisStore({
url: `redis://${config.get("REDIS_HOST")}:${config.get("REDIS_PORT")}`,
}),
ttl: 600, // 10 minutes
}),
}),
],
controllers: [AppController],
controllers: [AppController, HealthController],
providers: [AppService],
})
export class AppModule {}

View File

@@ -0,0 +1,119 @@
import { Body, Controller, Headers, Post, Req, Res } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Request, Response } from "express";
import { getIronSession } from "iron-session";
import { AuthService } from "./auth.service";
import { LoginDto } from "./dto/login.dto";
import { RegisterDto } from "./dto/register.dto";
import { Verify2faDto } from "./dto/verify-2fa.dto";
import { getSessionOptions, SessionData } from "./session.config";
@Controller("auth")
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly configService: ConfigService,
) {}
@Post("register")
register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto);
}
@Post("login")
async login(
@Body() loginDto: LoginDto,
@Headers("user-agent") userAgent: string,
@Req() req: Request,
@Res() res: Response,
) {
const ip = req.ip;
const result = await this.authService.login(loginDto, userAgent, ip);
if (result.access_token) {
const session = await getIronSession<SessionData>(
req,
res,
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
);
session.accessToken = result.access_token;
session.refreshToken = result.refresh_token;
session.userId = result.userId;
await session.save();
// On ne renvoie pas les tokens dans le body pour plus de sécurité
return res.json({
message: result.message,
userId: result.userId,
});
}
return res.json(result);
}
@Post("verify-2fa")
async verifyTwoFactor(
@Body() verify2faDto: Verify2faDto,
@Headers("user-agent") userAgent: string,
@Req() req: Request,
@Res() res: Response,
) {
const ip = req.ip;
const result = await this.authService.verifyTwoFactorLogin(
verify2faDto.userId,
verify2faDto.token,
userAgent,
ip,
);
if (result.access_token) {
const session = await getIronSession<SessionData>(
req,
res,
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
);
session.accessToken = result.access_token;
session.refreshToken = result.refresh_token;
session.userId = verify2faDto.userId;
await session.save();
return res.json({
message: result.message,
});
}
return res.json(result);
}
@Post("refresh")
async refresh(@Req() req: Request, @Res() res: Response) {
const session = await getIronSession<SessionData>(
req,
res,
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
);
if (!session.refreshToken) {
return res.status(401).json({ message: "No refresh token" });
}
const result = await this.authService.refresh(session.refreshToken);
session.accessToken = result.access_token;
session.refreshToken = result.refresh_token;
await session.save();
return res.json({ message: "Token refreshed" });
}
@Post("logout")
async logout(@Req() req: Request, @Res() res: Response) {
const session = await getIronSession<SessionData>(
req,
res,
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
);
session.destroy();
return res.json({ message: "User logged out" });
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from "@nestjs/common";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { SessionsModule } from "../sessions/sessions.module";
import { UsersModule } from "../users/users.module";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { RbacService } from "./rbac.service";
@Module({
imports: [UsersModule, CryptoModule, SessionsModule, DatabaseModule],
controllers: [AuthController],
providers: [AuthService, RbacService],
exports: [AuthService, RbacService],
})
export class AuthModule {}

View File

@@ -0,0 +1,197 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { authenticator } from "otplib";
import { toDataURL } from "qrcode";
import { CryptoService } from "../crypto/crypto.service";
import { SessionsService } from "../sessions/sessions.service";
import { UsersService } from "../users/users.service";
import { LoginDto } from "./dto/login.dto";
import { RegisterDto } from "./dto/register.dto";
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly cryptoService: CryptoService,
private readonly sessionsService: SessionsService,
private readonly configService: ConfigService,
) {}
async generateTwoFactorSecret(userId: string) {
const user = await this.usersService.findOne(userId);
if (!user) throw new UnauthorizedException();
const secret = authenticator.generateSecret();
const otpauthUrl = authenticator.keyuri(
user.username,
this.configService.get("DOMAIN_NAME") || "Memegoat",
secret,
);
await this.usersService.setTwoFactorSecret(userId, secret);
const qrCodeDataUrl = await toDataURL(otpauthUrl);
return {
secret,
qrCodeDataUrl,
};
}
async enableTwoFactor(userId: string, token: string) {
const secret = await this.usersService.getTwoFactorSecret(userId);
if (!secret) {
throw new BadRequestException("2FA not initiated");
}
const isValid = authenticator.verify({ token, secret });
if (!isValid) {
throw new BadRequestException("Invalid 2FA token");
}
await this.usersService.toggleTwoFactor(userId, true);
return { message: "2FA enabled successfully" };
}
async disableTwoFactor(userId: string, token: string) {
const secret = await this.usersService.getTwoFactorSecret(userId);
if (!secret) {
throw new BadRequestException("2FA not enabled");
}
const isValid = authenticator.verify({ token, secret });
if (!isValid) {
throw new BadRequestException("Invalid 2FA token");
}
await this.usersService.toggleTwoFactor(userId, false);
return { message: "2FA disabled successfully" };
}
async register(dto: RegisterDto) {
const { username, email, password } = dto;
const passwordHash = await this.cryptoService.hashPassword(password);
const emailHash = await this.cryptoService.hashEmail(email);
const user = await this.usersService.create({
username,
email,
passwordHash,
emailHash,
});
return {
message: "User registered successfully",
userId: user.uuid,
};
}
async login(dto: LoginDto, userAgent?: string, ip?: string) {
const { email, password } = dto;
const emailHash = await this.cryptoService.hashEmail(email);
const user = await this.usersService.findByEmailHash(emailHash);
if (!user) {
throw new UnauthorizedException("Invalid credentials");
}
const isPasswordValid = await this.cryptoService.verifyPassword(
password,
user.passwordHash,
);
if (!isPasswordValid) {
throw new UnauthorizedException("Invalid credentials");
}
if (user.isTwoFactorEnabled) {
return {
message: "2FA required",
requires2FA: true,
userId: user.uuid,
};
}
const accessToken = await this.cryptoService.generateJwt({
sub: user.uuid,
username: user.username,
});
const session = await this.sessionsService.createSession(
user.uuid,
userAgent,
ip,
);
return {
message: "User logged in successfully",
access_token: accessToken,
refresh_token: session.refreshToken,
};
}
async verifyTwoFactorLogin(
userId: string,
token: string,
userAgent?: string,
ip?: string,
) {
const user = await this.usersService.findOneWithPrivateData(userId);
if (!user || !user.isTwoFactorEnabled) {
throw new UnauthorizedException();
}
const secret = await this.usersService.getTwoFactorSecret(userId);
if (!secret) throw new UnauthorizedException();
const isValid = authenticator.verify({ token, secret });
if (!isValid) {
throw new UnauthorizedException("Invalid 2FA token");
}
const accessToken = await this.cryptoService.generateJwt({
sub: user.uuid,
username: user.username,
});
const session = await this.sessionsService.createSession(
user.uuid,
userAgent,
ip,
);
return {
message: "User logged in successfully (2FA)",
access_token: accessToken,
refresh_token: session.refreshToken,
};
}
async refresh(refreshToken: string) {
const session = await this.sessionsService.refreshSession(refreshToken);
const user = await this.usersService.findOne(session.userId);
if (!user) {
throw new UnauthorizedException("User not found");
}
const accessToken = await this.cryptoService.generateJwt({
sub: user.uuid,
username: user.username,
});
return {
access_token: accessToken,
refresh_token: session.refreshToken,
};
}
async logout() {
return { message: "User logged out" };
}
}

View File

@@ -0,0 +1,3 @@
import { SetMetadata } from "@nestjs/common";
export const Roles = (...roles: string[]) => SetMetadata("roles", roles);

View File

@@ -0,0 +1,10 @@
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
@IsNotEmpty()
password!: string;
}

View File

@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from "class-validator";
export class RefreshDto {
@IsString()
@IsNotEmpty()
refresh_token!: string;
}

View File

@@ -0,0 +1,14 @@
import { IsEmail, IsNotEmpty, IsString, MinLength } from "class-validator";
export class RegisterDto {
@IsString()
@IsNotEmpty()
username!: string;
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
}

View File

@@ -0,0 +1,10 @@
import { IsNotEmpty, IsString, IsUUID } from "class-validator";
export class Verify2faDto {
@IsUUID()
userId!: string;
@IsString()
@IsNotEmpty()
token!: string;
}

View File

@@ -0,0 +1,44 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { getIronSession } from "iron-session";
import { CryptoService } from "../../crypto/crypto.service";
import { getSessionOptions, SessionData } from "../session.config";
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private readonly cryptoService: CryptoService,
private readonly configService: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
const session = await getIronSession<SessionData>(
request,
response,
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
);
const token = session.accessToken;
if (!token) {
throw new UnauthorizedException();
}
try {
const payload = await this.cryptoService.verifyJwt(token);
request.user = payload;
} catch {
throw new UnauthorizedException();
}
return true;
}
}

View File

@@ -0,0 +1,28 @@
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { RbacService } from "../rbac.service";
@Injectable()
export class RolesGuard implements CanActivate {
constructor(
private reflector: Reflector,
private rbacService: RbacService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredRoles = this.reflector.getAllAndOverride<string[]>("roles", [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user) {
return false;
}
const userRoles = await this.rbacService.getUserRoles(user.sub);
return requiredRoles.some((role) => userRoles.includes(role));
}
}

View File

@@ -0,0 +1,42 @@
import { Injectable } from "@nestjs/common";
import { eq } from "drizzle-orm";
import { DatabaseService } from "../database/database.service";
import {
permissions,
roles,
rolesToPermissions,
usersToRoles,
} from "../database/schemas";
@Injectable()
export class RbacService {
constructor(private readonly databaseService: DatabaseService) {}
async getUserRoles(userId: string) {
const result = await this.databaseService.db
.select({
slug: roles.slug,
})
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, userId));
return result.map((r) => r.slug);
}
async getUserPermissions(userId: string) {
const result = await this.databaseService.db
.select({
slug: permissions.slug,
})
.from(usersToRoles)
.innerJoin(
rolesToPermissions,
eq(usersToRoles.roleId, rolesToPermissions.roleId),
)
.innerJoin(permissions, eq(rolesToPermissions.permissionId, permissions.id))
.where(eq(usersToRoles.userId, userId));
return Array.from(new Set(result.map((p) => p.slug)));
}
}

View File

@@ -0,0 +1,18 @@
import { SessionOptions } from "iron-session";
export interface SessionData {
accessToken?: string;
refreshToken?: string;
userId?: string;
}
export const getSessionOptions = (password: string): SessionOptions => ({
password,
cookieName: "memegoat_session",
cookieOptions: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
sameSite: "strict",
maxAge: 60 * 60 * 24 * 7, // 7 days
},
});

View File

@@ -0,0 +1,43 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
} from "@nestjs/common";
import { CategoriesService } from "./categories.service";
import { CreateCategoryDto } from "./dto/create-category.dto";
import { UpdateCategoryDto } from "./dto/update-category.dto";
@Controller("categories")
export class CategoriesController {
constructor(private readonly categoriesService: CategoriesService) {}
@Get()
findAll() {
return this.categoriesService.findAll();
}
@Get(":id")
findOne(@Param("id") id: string) {
return this.categoriesService.findOne(id);
}
// Ces routes devraient être protégées par un AdminGuard
@Post()
create(@Body() createCategoryDto: CreateCategoryDto) {
return this.categoriesService.create(createCategoryDto);
}
@Patch(":id")
update(@Param("id") id: string, @Body() updateCategoryDto: UpdateCategoryDto) {
return this.categoriesService.update(id, updateCategoryDto);
}
@Delete(":id")
remove(@Param("id") id: string) {
return this.categoriesService.remove(id);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module";
import { CategoriesController } from "./categories.controller";
import { CategoriesService } from "./categories.service";
@Module({
imports: [DatabaseModule],
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],
})
export class CategoriesModule {}

View File

@@ -0,0 +1,64 @@
import { Injectable } from "@nestjs/common";
import { eq } from "drizzle-orm";
import { DatabaseService } from "../database/database.service";
import { categories } from "../database/schemas";
import { CreateCategoryDto } from "./dto/create-category.dto";
import { UpdateCategoryDto } from "./dto/update-category.dto";
@Injectable()
export class CategoriesService {
constructor(private readonly databaseService: DatabaseService) {}
async findAll() {
return await this.databaseService.db
.select()
.from(categories)
.orderBy(categories.name);
}
async findOne(id: string) {
const result = await this.databaseService.db
.select()
.from(categories)
.where(eq(categories.id, id))
.limit(1);
return result[0] || null;
}
async create(data: CreateCategoryDto) {
const slug = data.name
.toLowerCase()
.replace(/ /g, "-")
.replace(/[^\w-]/g, "");
return await this.databaseService.db
.insert(categories)
.values({ ...data, slug })
.returning();
}
async update(id: string, data: UpdateCategoryDto) {
const updateData = {
...data,
updatedAt: new Date(),
slug: data.name
? data.name
.toLowerCase()
.replace(/ /g, "-")
.replace(/[^\w-]/g, "")
: undefined,
};
return await this.databaseService.db
.update(categories)
.set(updateData)
.where(eq(categories.id, id))
.returning();
}
async remove(id: string) {
return await this.databaseService.db
.delete(categories)
.where(eq(categories.id, id))
.returning();
}
}

View File

@@ -0,0 +1,15 @@
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
export class CreateCategoryDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
iconUrl?: string;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from "@nestjs/mapped-types";
import { CreateCategoryDto } from "./create-category.dto";
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}

View File

@@ -0,0 +1,11 @@
import { Global, Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module";
import { PurgeService } from "./services/purge.service";
@Global()
@Module({
imports: [DatabaseModule],
providers: [PurgeService],
exports: [PurgeService],
})
export class CommonModule {}

View File

@@ -0,0 +1,56 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from "@nestjs/common";
import * as Sentry from "@sentry/nestjs";
import { Request, Response } from "express";
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger("ExceptionFilter");
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException
? exception.getResponse()
: "Internal server error";
const errorResponse = {
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
message:
typeof message === "object" && message !== null
? (message as Record<string, unknown>).message || message
: message,
};
if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
Sentry.captureException(exception);
this.logger.error(
`${request.method} ${request.url} - Error: ${exception instanceof Error ? exception.message : "Unknown error"}`,
exception instanceof Error ? exception.stack : "",
);
} else {
this.logger.warn(
`${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`,
);
}
response.status(status).json(errorResponse);
}
}

View File

@@ -0,0 +1,8 @@
import { Request } from "express";
export interface AuthenticatedRequest extends Request {
user: {
sub: string;
username: string;
};
}

View File

@@ -0,0 +1,63 @@
import { Injectable, Logger } from "@nestjs/common";
import { Cron, CronExpression } from "@nestjs/schedule";
import { and, eq, isNotNull, lte } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import { contents, reports, sessions, users } from "../../database/schemas";
@Injectable()
export class PurgeService {
private readonly logger = new Logger(PurgeService.name);
constructor(private readonly databaseService: DatabaseService) {}
// Toutes les nuits à minuit
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async purgeExpiredData() {
this.logger.log("Starting automatic data purge...");
try {
const now = new Date();
// 1. Purge des sessions expirées
const deletedSessions = await this.databaseService.db
.delete(sessions)
.where(lte(sessions.expiresAt, now))
.returning();
this.logger.log(`Purged ${deletedSessions.length} expired sessions.`);
// 2. Purge des signalements obsolètes
const deletedReports = await this.databaseService.db
.delete(reports)
.where(lte(reports.expiresAt, now))
.returning();
this.logger.log(`Purged ${deletedReports.length} obsolete reports.`);
// 3. Purge des utilisateurs supprimés (Soft Delete > 30 jours)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const deletedUsers = await this.databaseService.db
.delete(users)
.where(
and(eq(users.status, "deleted"), lte(users.deletedAt, thirtyDaysAgo)),
)
.returning();
this.logger.log(
`Purged ${deletedUsers.length} users marked for deletion more than 30 days ago.`,
);
// 4. Purge des contenus supprimés (Soft Delete > 30 jours)
const deletedContents = await this.databaseService.db
.delete(contents)
.where(
and(isNotNull(contents.deletedAt), lte(contents.deletedAt, thirtyDaysAgo)),
)
.returning();
this.logger.log(
`Purged ${deletedContents.length} contents marked for deletion more than 30 days ago.`,
);
} catch (error) {
this.logger.error("Error during data purge", error);
}
}
}

View File

@@ -0,0 +1,63 @@
import { z } from "zod";
export const envSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.coerce.number().default(3000),
// Database
POSTGRES_HOST: z.string(),
POSTGRES_PORT: z.coerce.number().default(5432),
POSTGRES_DB: z.string(),
POSTGRES_USER: z.string(),
POSTGRES_PASSWORD: z.string(),
// S3
S3_ENDPOINT: z.string().default("localhost"),
S3_PORT: z.coerce.number().default(9000),
S3_USE_SSL: z.preprocess((val) => val === "true", z.boolean()).default(false),
S3_ACCESS_KEY: z.string().default("minioadmin"),
S3_SECRET_KEY: z.string().default("minioadmin"),
S3_BUCKET_NAME: z.string().default("memegoat"),
// Security
JWT_SECRET: z.string().min(32),
ENCRYPTION_KEY: z.string().length(32),
PGP_ENCRYPTION_KEY: z.string().min(16),
// Mail
MAIL_HOST: z.string(),
MAIL_PORT: z.coerce.number(),
MAIL_SECURE: z.preprocess((val) => val === "true", z.boolean()).default(false),
MAIL_USER: z.string(),
MAIL_PASS: z.string(),
MAIL_FROM: z.string().email(),
DOMAIN_NAME: z.string(),
// Sentry
SENTRY_DSN: z.string().optional(),
// Redis
REDIS_HOST: z.string().default("localhost"),
REDIS_PORT: z.coerce.number().default(6379),
// Session
SESSION_PASSWORD: z.string().min(32),
// Media Limits
MAX_IMAGE_SIZE_KB: z.coerce.number().default(512),
MAX_GIF_SIZE_KB: z.coerce.number().default(1024),
});
export type Env = z.infer<typeof envSchema>;
export function validateEnv(config: Record<string, unknown>) {
const result = envSchema.safeParse(config);
if (!result.success) {
console.error("❌ Invalid environment variables:", result.error.format());
throw new Error("Invalid environment variables");
}
return result.data;
}

View File

@@ -0,0 +1,179 @@
import { CacheInterceptor, CacheTTL } from "@nestjs/cache-manager";
import {
Body,
Controller,
DefaultValuePipe,
Delete,
Get,
Header,
NotFoundException,
Param,
ParseBoolPipe,
ParseIntPipe,
Post,
Query,
Req,
Res,
UploadedFile,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import type { Request, Response } from "express";
import { AuthGuard } from "../auth/guards/auth.guard";
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { ContentsService } from "./contents.service";
import { CreateContentDto } from "./dto/create-content.dto";
import { UploadContentDto } from "./dto/upload-content.dto";
@Controller("contents")
export class ContentsController {
constructor(private readonly contentsService: ContentsService) {}
@Post()
@UseGuards(AuthGuard)
create(
@Req() req: AuthenticatedRequest,
@Body() createContentDto: CreateContentDto,
) {
return this.contentsService.create(req.user.sub, createContentDto);
}
@Post("upload-url")
@UseGuards(AuthGuard)
getUploadUrl(
@Req() req: AuthenticatedRequest,
@Query("fileName") fileName: string,
) {
return this.contentsService.getUploadUrl(req.user.sub, fileName);
}
@Post("upload")
@UseGuards(AuthGuard)
@UseInterceptors(FileInterceptor("file"))
upload(
@Req() req: AuthenticatedRequest,
@UploadedFile()
file: Express.Multer.File,
@Body() uploadContentDto: UploadContentDto,
) {
return this.contentsService.uploadAndProcess(
req.user.sub,
file,
uploadContentDto,
);
}
@Get("explore")
@UseInterceptors(CacheInterceptor)
@CacheTTL(60)
@Header("Cache-Control", "public, max-age=60")
explore(
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
@Query("sort") sort?: "trend" | "recent",
@Query("tag") tag?: string,
@Query("category") category?: string,
@Query("author") author?: string,
@Query("query") query?: string,
@Query("favoritesOnly", new DefaultValuePipe(false), ParseBoolPipe)
favoritesOnly?: boolean,
@Query("userId") userId?: string,
) {
return this.contentsService.findAll({
limit,
offset,
sortBy: sort,
tag,
category,
author,
query,
favoritesOnly,
userId,
});
}
@Get("trends")
@UseInterceptors(CacheInterceptor)
@CacheTTL(300)
@Header("Cache-Control", "public, max-age=300")
trends(
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
) {
return this.contentsService.findAll({ limit, offset, sortBy: "trend" });
}
@Get("recent")
@UseInterceptors(CacheInterceptor)
@CacheTTL(60)
@Header("Cache-Control", "public, max-age=60")
recent(
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
) {
return this.contentsService.findAll({ limit, offset, sortBy: "recent" });
}
@Get(":idOrSlug")
@UseInterceptors(CacheInterceptor)
@CacheTTL(3600)
@Header("Cache-Control", "public, max-age=3600")
async findOne(
@Param("idOrSlug") idOrSlug: string,
@Req() req: Request,
@Res() res: Response,
) {
const content = await this.contentsService.findOne(idOrSlug);
if (!content) {
throw new NotFoundException("Contenu non trouvé");
}
const userAgent = req.headers["user-agent"] || "";
const isBot =
/bot|googlebot|crawler|spider|robot|crawling|facebookexternalhit|twitterbot/i.test(
userAgent,
);
if (isBot) {
const imageUrl = this.contentsService.getFileUrl(content.storageKey);
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${content.title}</title>
<meta property="og:title" content="${content.title}" />
<meta property="og:type" content="website" />
<meta property="og:image" content="${imageUrl}" />
<meta property="og:description" content="Découvrez ce meme sur Memegoat" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${content.title}" />
<meta name="twitter:image" content="${imageUrl}" />
</head>
<body>
<h1>${content.title}</h1>
<img src="${imageUrl}" alt="${content.title}" />
</body>
</html>`;
return res.send(html);
}
return res.json(content);
}
@Post(":id/view")
incrementViews(@Param("id") id: string) {
return this.contentsService.incrementViews(id);
}
@Post(":id/use")
incrementUsage(@Param("id") id: string) {
return this.contentsService.incrementUsage(id);
}
@Delete(":id")
@UseGuards(AuthGuard)
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
return this.contentsService.remove(id, req.user.sub);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { MediaModule } from "../media/media.module";
import { S3Module } from "../s3/s3.module";
import { ContentsController } from "./contents.controller";
import { ContentsService } from "./contents.service";
@Module({
imports: [DatabaseModule, S3Module, AuthModule, CryptoModule, MediaModule],
controllers: [ContentsController],
providers: [ContentsService],
})
export class ContentsModule {}

View File

@@ -0,0 +1,349 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import {
and,
desc,
eq,
exists,
ilike,
isNull,
type SQL,
sql,
} from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import { DatabaseService } from "../database/database.service";
import {
categories,
contents,
contentsToTags,
favorites,
tags,
users,
} from "../database/schemas";
import type { MediaProcessingResult } from "../media/interfaces/media.interface";
import { MediaService } from "../media/media.service";
import { S3Service } from "../s3/s3.service";
import { CreateContentDto } from "./dto/create-content.dto";
@Injectable()
export class ContentsService {
constructor(
private readonly databaseService: DatabaseService,
private readonly s3Service: S3Service,
private readonly mediaService: MediaService,
private readonly configService: ConfigService,
) {}
async getUploadUrl(userId: string, fileName: string) {
const key = `uploads/${userId}/${Date.now()}-${fileName}`;
const url = await this.s3Service.getUploadUrl(key);
return { url, key };
}
async uploadAndProcess(
userId: string,
file: Express.Multer.File,
data: {
title: string;
type: "meme" | "gif";
categoryId?: string;
tags?: string[];
},
) {
// 0. Validation du format et de la taille
const allowedMimeTypes = [
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
"video/webm",
];
if (!allowedMimeTypes.includes(file.mimetype)) {
throw new BadRequestException(
"Format de fichier non supporté. Formats acceptés: png, jpeg, jpg, webp, webm, gif.",
);
}
const isGif = file.mimetype === "image/gif";
const maxSizeKb = isGif
? this.configService.get<number>("MAX_GIF_SIZE_KB", 1024)
: this.configService.get<number>("MAX_IMAGE_SIZE_KB", 512);
if (file.size > maxSizeKb * 1024) {
throw new BadRequestException(
`Fichier trop volumineux. Limite pour ${isGif ? "GIF" : "image"}: ${maxSizeKb} Ko.`,
);
}
// 1. Scan Antivirus
const scanResult = await this.mediaService.scanFile(
file.buffer,
file.originalname,
);
if (scanResult.isInfected) {
throw new BadRequestException(
`Le fichier est infecté par ${scanResult.virusName}`,
);
}
// 2. Transcodage
let processed: MediaProcessingResult;
if (file.mimetype.startsWith("image/")) {
// Image ou GIF -> WebP (format moderne, bien supporté)
processed = await this.mediaService.processImage(file.buffer, "webp");
} else if (file.mimetype.startsWith("video/")) {
// Vidéo -> WebM
processed = await this.mediaService.processVideo(file.buffer, "webm");
} else {
throw new BadRequestException("Format de fichier non supporté");
}
// 3. Upload vers S3
const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`;
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
// 4. Création en base de données
return await this.create(userId, {
...data,
storageKey: key,
mimeType: processed.mimeType,
fileSize: processed.size,
});
}
async findAll(options: {
limit: number;
offset: number;
sortBy?: "trend" | "recent";
tag?: string;
category?: string; // Slug ou ID
author?: string;
query?: string;
favoritesOnly?: boolean;
userId?: string; // Nécessaire si favoritesOnly est vrai
}) {
const {
limit,
offset,
sortBy,
tag,
category,
author,
query,
favoritesOnly,
userId,
} = options;
let whereClause: SQL | undefined = isNull(contents.deletedAt);
if (tag) {
whereClause = and(
whereClause,
exists(
this.databaseService.db
.select()
.from(contentsToTags)
.innerJoin(tags, eq(contentsToTags.tagId, tags.id))
.where(
and(eq(contentsToTags.contentId, contents.id), eq(tags.name, tag)),
),
),
);
}
if (author) {
whereClause = and(
whereClause,
exists(
this.databaseService.db
.select()
.from(users)
.where(and(eq(users.uuid, contents.userId), eq(users.username, author))),
),
);
}
if (category) {
whereClause = and(
whereClause,
exists(
this.databaseService.db
.select()
.from(categories)
.where(
and(
eq(categories.id, contents.categoryId),
sql`(${categories.slug} = ${category} OR ${categories.id}::text = ${category})`,
),
),
),
);
}
if (query) {
whereClause = and(whereClause, ilike(contents.title, `%${query}%`));
}
if (favoritesOnly && userId) {
whereClause = and(
whereClause,
exists(
this.databaseService.db
.select()
.from(favorites)
.where(
and(eq(favorites.contentId, contents.id), eq(favorites.userId, userId)),
),
),
);
}
// Pagination Total Count
const totalCountResult = await this.databaseService.db
.select({ count: sql<number>`count(*)` })
.from(contents)
.where(whereClause);
const totalCount = Number(totalCountResult[0].count);
// Sorting
let orderBy: SQL = desc(contents.createdAt);
if (sortBy === "trend") {
orderBy = desc(sql`${contents.views} + ${contents.usageCount}`);
}
const data = await this.databaseService.db
.select()
.from(contents)
.where(whereClause)
.orderBy(orderBy)
.limit(limit)
.offset(offset);
return { data, totalCount };
}
async create(userId: string, data: CreateContentDto) {
const { tags: tagNames, ...contentData } = data;
const slug = await this.ensureUniqueSlug(contentData.title);
return await this.databaseService.db.transaction(async (tx) => {
const [newContent] = await tx
.insert(contents)
.values({ ...contentData, userId, slug })
.returning();
if (tagNames && tagNames.length > 0) {
for (const tagName of tagNames) {
const slug = tagName
.toLowerCase()
.replace(/ /g, "-")
.replace(/[^\w-]/g, "");
// Trouver ou créer le tag
let [tag] = await tx
.select()
.from(tags)
.where(eq(tags.slug, slug))
.limit(1);
if (!tag) {
[tag] = await tx
.insert(tags)
.values({ name: tagName, slug, userId })
.returning();
}
// Lier le tag au contenu
await tx
.insert(contentsToTags)
.values({ contentId: newContent.id, tagId: tag.id })
.onConflictDoNothing();
}
}
return newContent;
});
}
async incrementViews(id: string) {
return await this.databaseService.db
.update(contents)
.set({ views: sql`${contents.views} + 1` })
.where(eq(contents.id, id))
.returning();
}
async incrementUsage(id: string) {
return await this.databaseService.db
.update(contents)
.set({ usageCount: sql`${contents.usageCount} + 1` })
.where(eq(contents.id, id))
.returning();
}
async remove(id: string, userId: string) {
return await this.databaseService.db
.update(contents)
.set({ deletedAt: new Date() })
.where(and(eq(contents.id, id), eq(contents.userId, userId)))
.returning();
}
async findOne(idOrSlug: string) {
const [content] = await this.databaseService.db
.select()
.from(contents)
.where(
and(
isNull(contents.deletedAt),
sql`(${contents.id}::text = ${idOrSlug} OR ${contents.slug} = ${idOrSlug})`,
),
)
.limit(1);
return content;
}
getFileUrl(storageKey: string): string {
const endpoint = this.configService.get("S3_ENDPOINT");
const port = this.configService.get("S3_PORT");
const protocol =
this.configService.get("S3_USE_SSL") === true ? "https" : "http";
const bucket = this.configService.get("S3_BUCKET_NAME");
if (endpoint === "localhost" || endpoint === "127.0.0.1") {
return `${protocol}://${endpoint}:${port}/${bucket}/${storageKey}`;
}
return `${protocol}://${endpoint}/${bucket}/${storageKey}`;
}
private generateSlug(text: string): string {
return text
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
private async ensureUniqueSlug(title: string): Promise<string> {
const baseSlug = this.generateSlug(title) || "content";
let slug = baseSlug;
let counter = 1;
while (true) {
const [existing] = await this.databaseService.db
.select()
.from(contents)
.where(eq(contents.slug, slug))
.limit(1);
if (!existing) break;
slug = `${baseSlug}-${counter++}`;
}
return slug;
}
}

View File

@@ -0,0 +1,43 @@
import {
IsArray,
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from "class-validator";
export enum ContentType {
MEME = "meme",
GIF = "gif",
}
export class CreateContentDto {
@IsEnum(ContentType)
type!: "meme" | "gif";
@IsString()
@IsNotEmpty()
title!: string;
@IsString()
@IsNotEmpty()
storageKey!: string;
@IsString()
@IsNotEmpty()
mimeType!: string;
@IsInt()
fileSize!: number;
@IsOptional()
@IsUUID()
categoryId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
}

View File

@@ -0,0 +1,25 @@
import {
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from "class-validator";
import { ContentType } from "./create-content.dto";
export class UploadContentDto {
@IsEnum(ContentType)
type!: "meme" | "gif";
@IsString()
@IsNotEmpty()
title!: string;
@IsOptional()
@IsUUID()
categoryId?: string;
@IsOptional()
@IsString({ each: true })
tags?: string[];
}

View File

@@ -34,6 +34,31 @@ export class CryptoService {
);
}
// --- Blind Indexing (for search on encrypted data) ---
async hashEmail(email: string): Promise<string> {
const normalizedEmail = email.toLowerCase().trim();
const data = new TextEncoder().encode(normalizedEmail);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
async hashIp(ip: string): Promise<string> {
const data = new TextEncoder().encode(ip);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
getPgpEncryptionKey(): string {
return (
this.configService.get<string>("PGP_ENCRYPTION_KEY") || "default-pgp-key"
);
}
// --- Argon2 Hashing ---
async hashPassword(password: string): Promise<string> {

View File

@@ -0,0 +1,24 @@
import { index, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
export const categories = pgTable(
"categories",
{
id: uuid("id").primaryKey().defaultRandom(),
name: varchar("name", { length: 64 }).notNull().unique(),
slug: varchar("slug", { length: 64 }).notNull().unique(),
description: varchar("description", { length: 255 }),
iconUrl: varchar("icon_url", { length: 512 }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => ({
slugIdx: index("categories_slug_idx").on(table.slug),
}),
);
export type CategoryInDb = typeof categories.$inferSelect;
export type NewCategoryInDb = typeof categories.$inferInsert;

View File

@@ -8,6 +8,7 @@ import {
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { categories } from "./categories";
import { tags } from "./tags";
import { users } from "./users";
@@ -21,10 +22,16 @@ export const contents = pgTable(
.notNull()
.references(() => users.uuid, { onDelete: "cascade" }),
type: contentType("type").notNull(),
categoryId: uuid("category_id").references(() => categories.id, {
onDelete: "set null",
}),
title: varchar("title", { length: 255 }).notNull(),
slug: varchar("slug", { length: 255 }).notNull().unique(),
storageKey: varchar("storage_key", { length: 512 }).notNull().unique(), // Clé interne S3
mimeType: varchar("mime_type", { length: 128 }).notNull(), // Pour le Content-Type HTTP
fileSize: integer("file_size").notNull(), // Taille en octets
views: integer("views").notNull().default(0),
usageCount: integer("usage_count").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),

View File

@@ -0,0 +1,24 @@
import { pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core";
import { contents } from "./content";
import { users } from "./users";
export const favorites = pgTable(
"favorites",
{
userId: uuid("user_id")
.notNull()
.references(() => users.uuid, { onDelete: "cascade" }),
contentId: uuid("content_id")
.notNull()
.references(() => contents.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
pk: primaryKey({ columns: [t.userId, t.contentId] }),
}),
);
export type FavoriteInDb = typeof favorites.$inferSelect;
export type NewFavoriteInDb = typeof favorites.$inferInsert;

View File

@@ -1,6 +1,8 @@
export * from "./api_keys";
export * from "./audit_logs";
export * from "./categories";
export * from "./content";
export * from "./favorites";
export * from "./rbac";
export * from "./reports";
export * from "./sessions";

View File

@@ -1,4 +1,5 @@
import { index, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
import { users } from "./users";
export const tags = pgTable(
"tags",
@@ -6,6 +7,9 @@ export const tags = pgTable(
id: uuid("id").primaryKey().defaultRandom(),
name: varchar("name", { length: 64 }).notNull().unique(),
slug: varchar("slug", { length: 64 }).notNull().unique(),
userId: uuid("user_id").references(() => users.uuid, {
onDelete: "set null",
}), // Créateur du tag
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),

View File

@@ -0,0 +1,43 @@
import {
Controller,
DefaultValuePipe,
Delete,
Get,
Param,
ParseIntPipe,
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import { AuthGuard } from "../auth/guards/auth.guard";
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { FavoritesService } from "./favorites.service";
@UseGuards(AuthGuard)
@Controller("favorites")
export class FavoritesController {
constructor(private readonly favoritesService: FavoritesService) {}
@Post(":contentId")
add(@Req() req: AuthenticatedRequest, @Param("contentId") contentId: string) {
return this.favoritesService.addFavorite(req.user.sub, contentId);
}
@Delete(":contentId")
remove(
@Req() req: AuthenticatedRequest,
@Param("contentId") contentId: string,
) {
return this.favoritesService.removeFavorite(req.user.sub, contentId);
}
@Get()
list(
@Req() req: AuthenticatedRequest,
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
) {
return this.favoritesService.getUserFavorites(req.user.sub, limit, offset);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module";
import { FavoritesController } from "./favorites.controller";
import { FavoritesService } from "./favorites.service";
@Module({
imports: [DatabaseModule],
controllers: [FavoritesController],
providers: [FavoritesService],
exports: [FavoritesService],
})
export class FavoritesModule {}

View File

@@ -0,0 +1,63 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { and, eq } from "drizzle-orm";
import { DatabaseService } from "../database/database.service";
import { contents, favorites } from "../database/schemas";
@Injectable()
export class FavoritesService {
constructor(private readonly databaseService: DatabaseService) {}
async addFavorite(userId: string, contentId: string) {
// Vérifier si le contenu existe
const content = await this.databaseService.db
.select()
.from(contents)
.where(eq(contents.id, contentId))
.limit(1);
if (content.length === 0) {
throw new NotFoundException("Content not found");
}
try {
return await this.databaseService.db
.insert(favorites)
.values({ userId, contentId })
.returning();
} catch (_error) {
// Probablement une violation de clé primaire (déjà en favori)
throw new ConflictException("Content already in favorites");
}
}
async removeFavorite(userId: string, contentId: string) {
const result = await this.databaseService.db
.delete(favorites)
.where(and(eq(favorites.userId, userId), eq(favorites.contentId, contentId)))
.returning();
if (result.length === 0) {
throw new NotFoundException("Favorite not found");
}
return result[0];
}
async getUserFavorites(userId: string, limit: number, offset: number) {
const data = await this.databaseService.db
.select({
content: contents,
})
.from(favorites)
.innerJoin(contents, eq(favorites.contentId, contents.id))
.where(eq(favorites.userId, userId))
.limit(limit)
.offset(offset);
return data.map((item) => item.content);
}
}

View File

@@ -0,0 +1,28 @@
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) {}
@Get()
async check() {
try {
// Check database connection
await this.databaseService.db.execute(sql`SELECT 1`);
return {
status: "ok",
database: "connected",
timestamp: new Date().toISOString(),
};
} catch (error) {
return {
status: "error",
database: "disconnected",
message: error.message,
timestamp: new Date().toISOString(),
};
}
}
}

View File

@@ -1,8 +1,52 @@
import { Logger, ValidationPipe } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { NestFactory } from "@nestjs/core";
import * as Sentry from "@sentry/nestjs";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
import helmet from "helmet";
import { AppModule } from "./app.module";
import { AllExceptionsFilter } from "./common/filters/http-exception.filter";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);
const configService = app.get(ConfigService);
const logger = new Logger("Bootstrap");
const sentryDsn = configService.get<string>("SENTRY_DSN");
if (sentryDsn) {
Sentry.init({
dsn: sentryDsn,
integrations: [nodeProfilingIntegration()],
tracesSampleRate: 1.0,
profilesSampleRate: 1.0,
sendDefaultPii: false, // RGPD
});
}
// Sécurité
app.use(helmet());
app.enableCors({
origin:
configService.get("NODE_ENV") === "production"
? [configService.get("DOMAIN_NAME") as string]
: true,
credentials: true,
});
// Validation Globale
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
// Filtre d'exceptions global
app.useGlobalFilters(new AllExceptionsFilter());
const port = configService.get<number>("PORT") || 3000;
await app.listen(port);
logger.log(`Application is running on: http://localhost:${port}`);
}
bootstrap();

View File

@@ -0,0 +1,13 @@
export interface MediaProcessingResult {
buffer: Buffer;
mimeType: string;
extension: string;
width?: number;
height?: number;
size: number;
}
export interface ScanResult {
isInfected: boolean;
virusName?: string;
}

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { MediaService } from "./media.service";
@Module({
providers: [MediaService],
exports: [MediaService],
})
export class MediaModule {}

View File

@@ -0,0 +1,166 @@
import { readFile, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
import {
BadRequestException,
Injectable,
InternalServerErrorException,
Logger,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import * as NodeClam from "clamscan";
import ffmpeg from "fluent-ffmpeg";
import sharp from "sharp";
import { v4 as uuidv4 } from "uuid";
import type {
MediaProcessingResult,
ScanResult,
} from "./interfaces/media.interface";
interface ClamScanner {
scanStream(
stream: Readable,
): Promise<{ isInfected: boolean; viruses: string[] }>;
}
@Injectable()
export class MediaService {
private readonly logger = new Logger(MediaService.name);
private clamscan: ClamScanner | null = null;
private isClamAvInitialized = false;
constructor(private readonly configService: ConfigService) {
this.initClamScan();
}
private async initClamScan() {
try {
// @ts-expect-error
const scanner = await new NodeClam().init({
clamdscan: {
host: this.configService.get<string>("CLAMAV_HOST", "localhost"),
port: this.configService.get<number>("CLAMAV_PORT", 3310),
timeout: 60000,
},
preference: "clamdscan",
});
this.clamscan = scanner;
this.isClamAvInitialized = true;
this.logger.log("ClamAV scanner initialized successfully");
} catch (error) {
this.logger.warn(
`ClamAV scanner could not be initialized: ${error.message}. Antivirus scanning will be skipped.`,
);
}
}
async scanFile(buffer: Buffer, filename: string): Promise<ScanResult> {
if (!this.isClamAvInitialized || !this.clamscan) {
this.logger.warn("ClamAV not initialized, skipping scan");
return { isInfected: false };
}
try {
const stream = Readable.from(buffer);
const { isInfected, viruses } = await this.clamscan.scanStream(stream);
if (isInfected) {
this.logger.error(
`Virus detected in file ${filename}: ${viruses.join(", ")}`,
);
}
return {
isInfected,
virusName: isInfected ? viruses[0] : undefined,
};
} catch (error) {
this.logger.error(`Error scanning file ${filename}: ${error.message}`);
throw new InternalServerErrorException("Error during virus scan");
}
}
async processImage(
buffer: Buffer,
format: "webp" | "avif" = "webp",
): Promise<MediaProcessingResult> {
try {
let pipeline = sharp(buffer);
const metadata = await pipeline.metadata();
if (format === "webp") {
pipeline = pipeline.webp({ quality: 80, effort: 6 });
} else {
pipeline = pipeline.avif({ quality: 65, effort: 6 });
}
const processedBuffer = await pipeline.toBuffer();
return {
buffer: processedBuffer,
mimeType: `image/${format}`,
extension: format,
width: metadata.width,
height: metadata.height,
size: processedBuffer.length,
};
} catch (error) {
this.logger.error(`Error processing image: ${error.message}`);
throw new BadRequestException("Failed to process image");
}
}
async processVideo(
buffer: Buffer,
format: "webm" | "av1" = "webm",
): Promise<MediaProcessingResult> {
const tempInput = join(tmpdir(), `${uuidv4()}.tmp`);
const tempOutput = join(
tmpdir(),
`${uuidv4()}.${format === "av1" ? "mp4" : "webm"}`,
);
try {
await writeFile(tempInput, buffer);
await new Promise<void>((resolve, reject) => {
let command = ffmpeg(tempInput);
if (format === "webm") {
command = command
.toFormat("webm")
.videoCodec("libvpx-vp9")
.audioCodec("libopus")
.outputOptions("-crf 30", "-b:v 0");
} else {
command = command
.toFormat("mp4")
.videoCodec("libaom-av1")
.audioCodec("libopus")
.outputOptions("-crf 34", "-b:v 0", "-strict experimental");
}
command
.on("end", () => resolve())
.on("error", (err) => reject(err))
.save(tempOutput);
});
const processedBuffer = await readFile(tempOutput);
return {
buffer: processedBuffer,
mimeType: format === "av1" ? "video/mp4" : "video/webm",
extension: format === "av1" ? "mp4" : "webm",
size: processedBuffer.length,
};
} catch (error) {
this.logger.error(`Error processing video: ${error.message}`);
throw new BadRequestException("Failed to process video");
} finally {
await unlink(tempInput).catch(() => {});
await unlink(tempOutput).catch(() => {});
}
}
}

View File

@@ -0,0 +1,25 @@
import { IsEnum, IsOptional, IsString, IsUUID } from "class-validator";
export enum ReportReason {
INAPPROPRIATE = "inappropriate",
SPAM = "spam",
COPYRIGHT = "copyright",
OTHER = "other",
}
export class CreateReportDto {
@IsOptional()
@IsUUID()
contentId?: string;
@IsOptional()
@IsUUID()
tagId?: string;
@IsEnum(ReportReason)
reason!: "inappropriate" | "spam" | "copyright" | "other";
@IsOptional()
@IsString()
description?: string;
}

View File

@@ -0,0 +1,13 @@
import { IsEnum } from "class-validator";
export enum ReportStatus {
PENDING = "pending",
REVIEWED = "reviewed",
RESOLVED = "resolved",
DISMISSED = "dismissed",
}
export class UpdateReportStatusDto {
@IsEnum(ReportStatus)
status!: "pending" | "reviewed" | "resolved" | "dismissed";
}

View File

@@ -0,0 +1,54 @@
import {
Body,
Controller,
DefaultValuePipe,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import { Roles } from "../auth/decorators/roles.decorator";
import { AuthGuard } from "../auth/guards/auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { CreateReportDto } from "./dto/create-report.dto";
import { UpdateReportStatusDto } from "./dto/update-report-status.dto";
import { ReportsService } from "./reports.service";
@Controller("reports")
export class ReportsController {
constructor(private readonly reportsService: ReportsService) {}
@Post()
@UseGuards(AuthGuard)
create(
@Req() req: AuthenticatedRequest,
@Body() createReportDto: CreateReportDto,
) {
return this.reportsService.create(req.user.sub, createReportDto);
}
@Get()
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin", "moderator")
findAll(
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
) {
return this.reportsService.findAll(limit, offset);
}
@Patch(":id/status")
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin", "moderator")
updateStatus(
@Param("id") id: string,
@Body() updateReportStatusDto: UpdateReportStatusDto,
) {
return this.reportsService.updateStatus(id, updateReportStatusDto.status);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { ReportsController } from "./reports.controller";
import { ReportsService } from "./reports.service";
@Module({
imports: [DatabaseModule, AuthModule, CryptoModule],
controllers: [ReportsController],
providers: [ReportsService],
})
export class ReportsModule {}

View File

@@ -0,0 +1,44 @@
import { Injectable } from "@nestjs/common";
import { desc, eq } from "drizzle-orm";
import { DatabaseService } from "../database/database.service";
import { reports } from "../database/schemas";
import { CreateReportDto } from "./dto/create-report.dto";
@Injectable()
export class ReportsService {
constructor(private readonly databaseService: DatabaseService) {}
async create(reporterId: string, data: CreateReportDto) {
const [newReport] = await this.databaseService.db
.insert(reports)
.values({
reporterId,
contentId: data.contentId,
tagId: data.tagId,
reason: data.reason,
description: data.description,
})
.returning();
return newReport;
}
async findAll(limit: number, offset: number) {
return await this.databaseService.db
.select()
.from(reports)
.orderBy(desc(reports.createdAt))
.limit(limit)
.offset(offset);
}
async updateStatus(
id: string,
status: "pending" | "reviewed" | "resolved" | "dismissed",
) {
return await this.databaseService.db
.update(reports)
.set({ status, updatedAt: new Date() })
.where(eq(reports.id, id))
.returning();
}
}

View File

@@ -89,6 +89,26 @@ export class S3Service implements OnModuleInit {
}
}
async getUploadUrl(
fileName: string,
expiry = 3600,
bucketName: string = this.bucketName,
) {
try {
return await this.minioClient.presignedUrl(
"PUT",
bucketName,
fileName,
expiry,
);
} catch (error) {
this.logger.error(
`Error getting upload URL for ${bucketName}: ${error.message}`,
);
throw error;
}
}
async deleteFile(fileName: string, bucketName: string = this.bucketName) {
try {
await this.minioClient.removeObject(bucketName, fileName);

View File

@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { SessionsService } from "./sessions.service";
@Module({
imports: [DatabaseModule, CryptoModule],
providers: [SessionsService],
exports: [SessionsService],
})
export class SessionsModule {}

View File

@@ -0,0 +1,88 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { and, eq } from "drizzle-orm";
import { CryptoService } from "../crypto/crypto.service";
import { DatabaseService } from "../database/database.service";
import { sessions } from "../database/schemas";
@Injectable()
export class SessionsService {
constructor(
private readonly databaseService: DatabaseService,
private readonly cryptoService: CryptoService,
) {}
async createSession(userId: string, userAgent?: string, ip?: string) {
const refreshToken = await this.cryptoService.generateJwt(
{ sub: userId, type: "refresh" },
"7d",
);
const ipHash = ip ? await this.cryptoService.hashIp(ip) : null;
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
const [session] = await this.databaseService.db
.insert(sessions)
.values({
userId,
refreshToken,
userAgent,
ipHash,
expiresAt,
})
.returning();
return session;
}
async refreshSession(oldRefreshToken: string) {
const session = await this.databaseService.db
.select()
.from(sessions)
.where(
and(eq(sessions.refreshToken, oldRefreshToken), eq(sessions.isValid, true)),
)
.limit(1)
.then((res) => res[0]);
if (!session || session.expiresAt < new Date()) {
if (session) {
await this.revokeSession(session.id);
}
throw new UnauthorizedException("Invalid refresh token");
}
// Rotation du refresh token
const newRefreshToken = await this.cryptoService.generateJwt(
{ sub: session.userId, type: "refresh" },
"7d",
);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
const [updatedSession] = await this.databaseService.db
.update(sessions)
.set({
refreshToken: newRefreshToken,
expiresAt,
updatedAt: new Date(),
})
.where(eq(sessions.id, session.id))
.returning();
return updatedSession;
}
async revokeSession(sessionId: string) {
await this.databaseService.db
.update(sessions)
.set({ isValid: false, updatedAt: new Date() })
.where(eq(sessions.id, sessionId));
}
async revokeAllUserSessions(userId: string) {
await this.databaseService.db
.update(sessions)
.set({ isValid: false, updatedAt: new Date() })
.where(eq(sessions.userId, userId));
}
}

View File

@@ -0,0 +1,23 @@
import {
Controller,
DefaultValuePipe,
Get,
ParseIntPipe,
Query,
} from "@nestjs/common";
import { TagsService } from "./tags.service";
@Controller("tags")
export class TagsController {
constructor(private readonly tagsService: TagsService) {}
@Get()
findAll(
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
@Query("query") query?: string,
@Query("sort") sort?: "popular" | "recent",
) {
return this.tagsService.findAll({ limit, offset, query, sortBy: sort });
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module";
import { TagsController } from "./tags.controller";
import { TagsService } from "./tags.service";
@Module({
imports: [DatabaseModule],
controllers: [TagsController],
providers: [TagsService],
exports: [TagsService],
})
export class TagsModule {}

View File

@@ -0,0 +1,53 @@
import { Injectable } from "@nestjs/common";
import { desc, eq, ilike, sql } from "drizzle-orm";
import { DatabaseService } from "../database/database.service";
import { contentsToTags, tags } from "../database/schemas";
@Injectable()
export class TagsService {
constructor(private readonly databaseService: DatabaseService) {}
async findAll(options: {
limit: number;
offset: number;
query?: string;
sortBy?: "popular" | "recent";
}) {
const { limit, offset, query, sortBy } = options;
let whereClause = sql`1=1`;
if (query) {
whereClause = ilike(tags.name, `%${query}%`);
}
// Pour la popularité, on compte le nombre d'associations dans contentsToTags
if (sortBy === "popular") {
const data = await this.databaseService.db
.select({
id: tags.id,
name: tags.name,
slug: tags.slug,
count: sql<number>`count(${contentsToTags.contentId})`.as("usage_count"),
})
.from(tags)
.leftJoin(contentsToTags, eq(tags.id, contentsToTags.tagId))
.where(whereClause)
.groupBy(tags.id)
.orderBy(desc(sql`usage_count`))
.limit(limit)
.offset(offset);
return data;
}
const data = await this.databaseService.db
.select()
.from(tags)
.where(whereClause)
.orderBy(sortBy === "recent" ? desc(tags.createdAt) : desc(tags.name))
.limit(limit)
.offset(offset);
return data;
}
}

View File

@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from "class-validator";
export class UpdateConsentDto {
@IsString()
@IsNotEmpty()
termsVersion!: string;
@IsString()
@IsNotEmpty()
privacyVersion!: string;
}

View File

@@ -0,0 +1,8 @@
import { IsOptional, IsString, MaxLength } from "class-validator";
export class UpdateUserDto {
@IsOptional()
@IsString()
@MaxLength(32)
displayName?: string;
}

View File

@@ -0,0 +1,107 @@
import {
Body,
Controller,
DefaultValuePipe,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import { AuthService } from "../auth/auth.service";
import { Roles } from "../auth/decorators/roles.decorator";
import { AuthGuard } from "../auth/guards/auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { UpdateConsentDto } from "./dto/update-consent.dto";
import { UpdateUserDto } from "./dto/update-user.dto";
import { UsersService } from "./users.service";
@Controller("users")
export class UsersController {
constructor(
private readonly usersService: UsersService,
private readonly authService: AuthService,
) {}
// Gestion administrative des utilisateurs
@Get("admin")
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin")
findAll(
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
) {
return this.usersService.findAll(limit, offset);
}
// Listing public d'un profil
@Get("public/:username")
findPublicProfile(@Param("username") username: string) {
return this.usersService.findPublicProfile(username);
}
// Gestion de son propre compte
@Get("me")
@UseGuards(AuthGuard)
findMe(@Req() req: AuthenticatedRequest) {
return this.usersService.findOneWithPrivateData(req.user.sub);
}
@Get("me/export")
@UseGuards(AuthGuard)
exportMe(@Req() req: AuthenticatedRequest) {
return this.usersService.exportUserData(req.user.sub);
}
@Patch("me")
@UseGuards(AuthGuard)
updateMe(
@Req() req: AuthenticatedRequest,
@Body() updateUserDto: UpdateUserDto,
) {
return this.usersService.update(req.user.sub, updateUserDto);
}
@Patch("me/consent")
@UseGuards(AuthGuard)
updateConsent(
@Req() req: AuthenticatedRequest,
@Body() consentDto: UpdateConsentDto,
) {
return this.usersService.updateConsent(
req.user.sub,
consentDto.termsVersion,
consentDto.privacyVersion,
);
}
@Delete("me")
@UseGuards(AuthGuard)
removeMe(@Req() req: AuthenticatedRequest) {
return this.usersService.remove(req.user.sub);
}
// Double Authentification (2FA)
@Post("me/2fa/setup")
@UseGuards(AuthGuard)
setup2fa(@Req() req: AuthenticatedRequest) {
return this.authService.generateTwoFactorSecret(req.user.sub);
}
@Post("me/2fa/enable")
@UseGuards(AuthGuard)
enable2fa(@Req() req: AuthenticatedRequest, @Body("token") token: string) {
return this.authService.enableTwoFactor(req.user.sub, token);
}
@Post("me/2fa/disable")
@UseGuards(AuthGuard)
disable2fa(@Req() req: AuthenticatedRequest, @Body("token") token: string) {
return this.authService.disableTwoFactor(req.user.sub, token);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
@Module({
imports: [DatabaseModule, CryptoModule, AuthModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,224 @@
import { Injectable } from "@nestjs/common";
import { eq, sql } from "drizzle-orm";
import { CryptoService } from "../crypto/crypto.service";
import { DatabaseService } from "../database/database.service";
import { contents, favorites, users } from "../database/schemas";
import { UpdateUserDto } from "./dto/update-user.dto";
@Injectable()
export class UsersService {
constructor(
private readonly databaseService: DatabaseService,
private readonly cryptoService: CryptoService,
) {}
async create(data: {
username: string;
email: string;
passwordHash: string;
emailHash: string;
}) {
const pgpKey = this.cryptoService.getPgpEncryptionKey();
const [newUser] = await this.databaseService.db
.insert(users)
.values({
username: data.username,
email: sql`pgp_sym_encrypt(${data.email}, ${pgpKey})`,
emailHash: data.emailHash,
passwordHash: data.passwordHash,
})
.returning();
return newUser;
}
async findByEmailHash(emailHash: string) {
const pgpKey = this.cryptoService.getPgpEncryptionKey();
const result = await this.databaseService.db
.select({
uuid: users.uuid,
username: users.username,
email: sql<string>`pgp_sym_decrypt(${users.email}, ${pgpKey})`,
passwordHash: users.passwordHash,
status: users.status,
isTwoFactorEnabled: users.isTwoFactorEnabled,
})
.from(users)
.where(eq(users.emailHash, emailHash))
.limit(1);
return result[0] || null;
}
async findOneWithPrivateData(uuid: string) {
const pgpKey = this.cryptoService.getPgpEncryptionKey();
const result = await this.databaseService.db
.select({
uuid: users.uuid,
username: users.username,
email: sql<string>`pgp_sym_decrypt(${users.email}, ${pgpKey})`,
displayName: users.displayName,
status: users.status,
isTwoFactorEnabled: users.isTwoFactorEnabled,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
.from(users)
.where(eq(users.uuid, uuid))
.limit(1);
return result[0] || null;
}
async findAll(limit: number, offset: number) {
const totalCountResult = await this.databaseService.db
.select({ count: sql<number>`count(*)` })
.from(users);
const totalCount = Number(totalCountResult[0].count);
const data = await this.databaseService.db
.select({
uuid: users.uuid,
username: users.username,
displayName: users.displayName,
status: users.status,
createdAt: users.createdAt,
})
.from(users)
.limit(limit)
.offset(offset);
return { data, totalCount };
}
async findPublicProfile(username: string) {
const result = await this.databaseService.db
.select({
uuid: users.uuid,
username: users.username,
displayName: users.displayName,
createdAt: users.createdAt,
})
.from(users)
.where(eq(users.username, username))
.limit(1);
return result[0] || null;
}
async findOne(uuid: string) {
const result = await this.databaseService.db
.select()
.from(users)
.where(eq(users.uuid, uuid))
.limit(1);
return result[0] || null;
}
async update(uuid: string, data: UpdateUserDto) {
return await this.databaseService.db
.update(users)
.set({ ...data, updatedAt: new Date() })
.where(eq(users.uuid, uuid))
.returning();
}
async updateConsent(
uuid: string,
termsVersion: string,
privacyVersion: string,
) {
return await this.databaseService.db
.update(users)
.set({
termsVersion,
privacyVersion,
gdprAcceptedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(users.uuid, uuid))
.returning();
}
async setTwoFactorSecret(uuid: string, secret: string) {
const pgpKey = this.cryptoService.getPgpEncryptionKey();
return await this.databaseService.db
.update(users)
.set({
twoFactorSecret: sql`pgp_sym_encrypt(${secret}, ${pgpKey})`,
updatedAt: new Date(),
})
.where(eq(users.uuid, uuid))
.returning();
}
async toggleTwoFactor(uuid: string, enabled: boolean) {
return await this.databaseService.db
.update(users)
.set({
isTwoFactorEnabled: enabled,
updatedAt: new Date(),
})
.where(eq(users.uuid, uuid))
.returning();
}
async getTwoFactorSecret(uuid: string): Promise<string | null> {
const pgpKey = this.cryptoService.getPgpEncryptionKey();
const result = await this.databaseService.db
.select({
secret: sql<string>`pgp_sym_decrypt(${users.twoFactorSecret}, ${pgpKey})`,
})
.from(users)
.where(eq(users.uuid, uuid))
.limit(1);
return result[0]?.secret || null;
}
async exportUserData(uuid: string) {
const user = await this.findOneWithPrivateData(uuid);
if (!user) return null;
const userContents = await this.databaseService.db
.select()
.from(contents)
.where(eq(contents.userId, uuid));
const userFavorites = await this.databaseService.db
.select()
.from(favorites)
.where(eq(favorites.userId, uuid));
return {
profile: user,
contents: userContents,
favorites: userFavorites,
exportedAt: new Date(),
};
}
async remove(uuid: string) {
return await this.databaseService.db.transaction(async (tx) => {
// Soft delete de l'utilisateur
const userResult = await tx
.update(users)
.set({ status: "deleted", deletedAt: new Date() })
.where(eq(users.uuid, uuid))
.returning();
// Soft delete de tous ses contenus
await tx
.update(contents)
.set({ deletedAt: new Date() })
.where(eq(contents.userId, uuid));
return userResult;
});
}
}

22
documentation/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:22-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS build
WORKDIR /usr/src/app
COPY . .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run --filter @memegoat/documentation build
FROM base AS runtime
WORKDIR /app
COPY --from=build /usr/src/app/documentation/public ./documentation/public
COPY --from=build /usr/src/app/documentation/.next/standalone ./
COPY --from=build /usr/src/app/documentation/.next/static ./documentation/.next/static
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "documentation/server.js"]

View File

@@ -1,10 +1,27 @@
{
"index": "Introduction",
"---core---": {
"type": "separator",
"label": "Architecture Core"
},
"features": "Fonctionnalités",
"stack": "Stack Technologique",
"database": "Modèle de Données",
"api": "API & Intégrations",
"---security---": {
"type": "separator",
"label": "Sécurité & Conformité"
},
"security": "Sécurité",
"compliance": "Conformité (RGPD)",
"---api---": {
"type": "separator",
"label": "Intégrations & API"
},
"api": "API & Intégrations",
"api-reference": "Référence API",
"---ops---": {
"type": "separator",
"label": "Opérations"
},
"deployment": "Déploiement & Tests"
}

View File

@@ -0,0 +1,191 @@
---
title: Référence API
description: Documentation détaillée de tous les points de terminaison de l'API Memegoat.
---
## 📖 Référence API
Cette page documente tous les points de terminaison disponibles sur l'API Memegoat. L'URL de base de l'API est `https://api.memegoat.fr`.
<Callout type="info">
L'authentification est gérée via des **cookies HttpOnly sécurisés** (`iron-session`). Les tokens ne sont pas exposés dans le corps des réponses après connexion pour une sécurité maximale.
</Callout>
### 🔐 Authentification (`/auth`)
<Accordions>
<Accordion title="POST /auth/register">
Inscrit un nouvel utilisateur.
**Corps de la requête (JSON) :**
- `username` (string) : Nom d'utilisateur unique.
- `email` (string) : Adresse email valide.
- `password` (string) : Mot de passe (min. 8 caractères).
```json
{
"username": "goat_user",
"email": "user@memegoat.fr",
"password": "strong-password"
}
```
</Accordion>
<Accordion title="POST /auth/login">
Authentifie un utilisateur et retourne les jetons de session. Si la 2FA est activée, retourne un indicateur.
**Corps de la requête :**
- `email` (string)
- `password` (string)
**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">
Finalise la connexion si la 2FA est requise.
**Corps de la requête :**
- `userId` (uuid) : ID de l'utilisateur.
- `token` (string) : Code TOTP à 6 chiffres.
</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.
</Accordion>
<Accordion title="POST /auth/logout">
Invalide la session actuelle.
</Accordion>
</Accordions>
### 👤 Utilisateurs (`/users`)
<Accordions>
<Accordion title="GET /users/me">
Récupère les informations détaillées de l'utilisateur connecté. Requiert l'authentification.
</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 et les favoris.
</Accordion>
<Accordion title="PATCH /users/me">
Met à jour les informations du profil.
- `displayName` (string)
</Accordion>
<Accordion title="DELETE /users/me">
Marque le compte pour suppression (Soft Delete).
<Callout type="warn">
Les données sont définitivement purgées après un délai légal de 30 jours.
</Callout>
</Accordion>
<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="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 | /trends | /recent">
Recherche et filtre les contenus. Ces endpoints sont mis en cache (Redis + Navigateur).
**Query Params :**
- `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.
**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** 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 avec traitement automatique.
**Type :** `multipart/form-data`
**Champs :**
- `file` (binary) : png, jpeg, webp, webm, gif.
- `type` : `meme` | `gif`
- `title` : string
- `categoryId`? : uuid
- `tags`? : string[]
</Accordion>
<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.
</Accordion>
</Accordions>
### 📂 Catégories, ⭐ Favoris, 🚩 Signalements
<Accordions>
<Accordion title="Catégories (/categories)">
- `GET /categories` : Liste toutes les catégories.
- `POST /categories` : Création (Admin uniquement).
</Accordion>
<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="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>
### 🔑 Clés API & 🏷️ Tags
<Accordions>
<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="Tags (/tags)">
- `GET /tags` : Recherche de tags populaires ou récents.
**Params :** `query`, `sort`, `limit`.
</Accordion>
</Accordions>

View File

@@ -7,14 +7,18 @@ description: Documentation des API et services tiers
### Documentation API
Documentation MDX.
L'API Memegoat est documentée de manière exhaustive dans notre [Référence API](/docs/api-reference).
Vous y trouverez la liste de tous les points de terminaison, les formats de requête et de réponse, ainsi que les niveaux d'autorisation requis.
### Authentification
Le système utilise plusieurs méthodes d'authentification sécurisées :
- **Sessions (JWT)** : Utilisation de JSON Web Tokens signés pour les sessions utilisateurs via le web. Les sessions sont persistées en base de données (`sessions`) pour permettre la révocation (Logout) et le suivi des appareils connectés.
- **API Keys** : Pour les intégrations programmatiques. Les clés sont hachées en base de données (`key_hash`) et associées à un utilisateur. Elles peuvent être nommées et révoquées individuellement.
- **Double Authentification (2FA)** : Support natif (TOTP) avec secret chiffré en base de données.
Le système utilise plusieurs méthodes d'authentification sécurisées pour répondre à différents besoins :
<Cards>
<Card title="Sessions (JWT)" description="Utilisation de tokens signés pour les sessions web, persistés en base pour la révocation." />
<Card title="API Keys" description="Clés hachées (SHA-256) pour les intégrations programmatiques, révocables individuellement." />
<Card title="Double Authentification" description="Support TOTP natif avec secret chiffré PGP pour une sécurité maximale." />
</Cards>
### Webhooks / Services Externes

View File

@@ -9,9 +9,11 @@ Le projet Memegoat s'inscrit dans une démarche de respect de la vie privée et
### 🛡️ Principes Fondamentaux
- **Minimisation des données** : Seules les données strictement nécessaires au fonctionnement du service sont collectées.
- **Transparence** : Les utilisateurs sont informés de la finalité des traitements de leurs données.
- **Sécurité** : Mise en œuvre de mesures techniques et organisationnelles pour protéger les données.
<Cards>
<Card title="Minimisation" description="Seules les données strictement nécessaires sont collectées." />
<Card title="Transparence" description="Finalité des traitements explicitée aux utilisateurs." />
<Card title="Sécurité" description="Mesures techniques et organisationnelles de pointe." />
</Cards>
### 🔒 Mesures Techniques de Protection
@@ -20,19 +22,35 @@ Conformément à la section [Sécurité](/docs/security), les mesures suivantes
- **Hachage aveugle** : Pour permettre les opérations sur données chiffrées sans compromettre la confidentialité.
- **Hachage des mots de passe** : Utilisation de l'algorithme **Argon2id**.
- **Communications sécurisées** : Utilisation de **TLS 1.3** via Caddy.
- **Suivi des Erreurs (Sentry)** : Configuration conforme avec désactivation de l'envoi des PII (Personally Identifiable Information) et masquage des données sensibles.
### 👤 Droits des Utilisateurs
Conformément au RGPD, les utilisateurs disposent des droits suivants, facilités par l'architecture technique :
- **Droit à l'effacement (droit à l'oubli)** : Mis en œuvre via un mécanisme de **Soft Delete** (`deleted_at`), suivi d'une purge définitive des données après un délai de conservation légal.
- **Droit d'accès et portabilité** : L'utilisation de schémas structurés (Drizzle/PostgreSQL) permet l'extraction facile des données d'un utilisateur sur demande.
- **Gestion du consentement** : Suivi rigoureux des versions de CGU et de politique de confidentialité acceptées (`terms_version`, `privacy_version`, `gdpr_accepted_at`).
Conformément au RGPD, les utilisateurs disposent des droits suivants :
<Callout type="info" title="Droit à l'effacement">
Mis en œuvre via un mécanisme de **Soft Delete** (`deleted_at`), suivi d'une purge définitive après 30 jours.
</Callout>
<Callout type="info" title="Portabilité">
Route dédiée `GET /users/me/export` permettant l'extraction immédiate au format JSON.
</Callout>
<Callout type="info" title="Consentement">
Suivi des versions de CGU et politique de confidentialité (`terms_version`, `privacy_version`).
</Callout>
### ⏳ Conservation et Purge
- **Purge Automatique** : Les données liées aux signalements (`reports`) disposent d'une date d'expiration (`expires_at`) pour garantir qu'elles ne sont pas conservées au-delà du nécessaire.
- **Anonymisation technique** : Les adresses IP stockées dans les tables `audit_logs` et `sessions` sont hachées (`ip_hash`), ce qui permet d'identifier des comportements malveillants tout en protégeant l'identité réelle de l'utilisateur.
- **Logs d'Audit** : Les journaux d'audit sont conservés pendant une période glissante pour répondre aux obligations de sécurité tout en respectant la minimisation.
- **Purge Automatique (Jobs Cron)** : Un service de purge planifiée s'exécute régulièrement pour :
- Supprimer définitivement les comptes et contenus marqués pour suppression depuis plus de 30 jours.
- Nettoyer les sessions expirées ou révoquées.
- Supprimer les signalements (`reports`) ayant atteint leur date d'expiration.
<Callout type="warn" title="Anonymisation">
Les adresses IP sont hachées (`ip_hash`) via SHA-256 dans les logs d'audit et de session.
</Callout>
- **Logs d'Audit** : Les journaux d'audit sont conservés pour répondre aux obligations de sécurité tout en respectant la minimisation.
### 📍 Hébergement des Données

View File

@@ -17,12 +17,16 @@ erDiagram
USER ||--o{ SESSION : "detient"
USER ||--o{ API_KEY : "genere"
USER ||--o{ AUDIT_LOG : "genere"
USER ||--o{ FAVORITE : "ajoute"
CONTENT ||--o{ CONTENT_TAG : "possede"
TAG ||--o{ CONTENT_TAG : "est_lie_a"
CONTENT ||--o{ REPORT : "est_signale"
CONTENT ||--o{ FAVORITE : "est_mis_en"
TAG ||--o{ REPORT : "est_signale"
CATEGORY ||--o{ CONTENT : "catégorise"
ROLE ||--o{ USER_ROLE : "attribue_a"
ROLE ||--o{ ROLE_PERMISSION : "possede"
PERMISSION ||--o{ ROLE_PERMISSION : "est_lie_a"
@@ -93,15 +97,32 @@ erDiagram
contents {
uuid id PK
uuid user_id FK
uuid category_id FK
content_type type
varchar title
varchar storage_key
varchar mime_type
integer file_size
integer views
integer usage_count
timestamp created_at
timestamp updated_at
timestamp deleted_at
}
categories {
uuid id PK
varchar name
varchar slug
varchar description
varchar icon_url
timestamp created_at
timestamp updated_at
}
favorites {
uuid user_id PK, FK
uuid content_id PK, FK
timestamp created_at
}
tags {
uuid id PK
varchar name
@@ -182,6 +203,9 @@ erDiagram
timestamp created_at
}
users ||--o{ favorites : "user_id"
contents ||--o{ favorites : "content_id"
categories ||--o{ contents : "category_id"
users ||--o{ contents : "user_id"
users ||--o{ users_to_roles : "user_id"
roles ||--o{ users_to_roles : "role_id"

View File

@@ -9,10 +9,67 @@ description: Procédures de déploiement et stratégie de tests
Un conteneur **Caddy** est utilisé en tant que reverse proxy pour fournir le TLS et la gestion du FQDN.
### Pré-requis
### Pré-requis Système
Liste des outils nécessaires (Node.js, pnpm, Docker).
<Cards>
<Card title="Environnement" description="Node.js >= 20, pnpm >= 10." />
<Card title="Base de données" description="PostgreSQL >= 15 + pgcrypto et Redis." />
<Card title="Stockage" description="MinIO ou S3 Compatible." />
<Card title="Services" description="ClamAV (clamd) et FFmpeg." />
</Cards>
## 🧪 Tests
### Procédure de Déploiement
- **Unitaires** : sur le backend
<Steps>
<Step>
### Configuration de l'environnement
Copiez le fichier `.env.example` vers `.env` et configurez les variables essentielles (clés PGP, secrets JWT, accès S3).
</Step>
<Step>
### Installation des dépendances
Utilisez pnpm pour installer les packages dans le monorepo :
```bash
pnpm install
```
</Step>
<Step>
### Initialisation de la base de données
Exécutez les migrations Drizzle pour créer les tables et les types nécessaires.
```bash
pnpm --filter backend db:migrate
```
</Step>
<Step>
### Lancement des services
Utilisez Docker Compose pour lancer l'infrastructure complète ou démarrez les services individuellement.
```bash
docker-compose up -d
```
</Step>
</Steps>
## 🧪 Tests & Qualité
<Tabs items={['Tests', 'Linting', 'Build']}>
<Tab value="Tests">
Exécutez la suite de tests unitaires avec Jest :
```bash
pnpm test
```
</Tab>
<Tab value="Linting">
Vérifiez la conformité du code avec Biome :
```bash
pnpm lint
```
</Tab>
<Tab value="Build">
Validez la compilation de tous les modules :
```bash
pnpm build
```
</Tab>
</Tabs>

View File

@@ -3,55 +3,73 @@ title: Fonctionnalités Techniques
description: Détails des fonctionnalités clés du projet Memegoat
---
## 🚀 Fonctionnalités Techniques
# 🚀 Fonctionnalités Techniques
Le projet Memegoat intègre un ensemble de fonctionnalités avancées pour garantir une expérience utilisateur fluide, sécurisée et performante.
### 📧 Emailing
Le système intègre un service d'envoi d'emails pour :
- La vérification des comptes lors de l'inscription.
- La récupération de mots de passe.
- Les notifications de sécurité (nouvelles connexions, changements de profil).
- Les alertes de modération.
## 🏗️ Infrastructure & Médias
### 📤 Publication & Traitement
Le coeur de la plateforme permet la publication sécurisée de mèmes et de GIFs avec un pipeline de traitement complet :
<Cards>
<Card icon="🛡️" title="Sécurité (Antivirus)" description="Chaque fichier uploadé est scanné en temps réel par ClamAV." />
<Card icon="🎞️" title="Transcodage" description="Conversion automatique vers WebP (images) et WebM (vidéos)." />
<Card icon="✅" title="Validation" description="Contrôle strict des formats (png, jpeg, webp, webm, gif) et des tailles." />
</Cards>
#### Détails du Pipeline :
- **Transcodage Haute Performance** :
- **Images & GIFs** : Conversion vers **WebP** (via `sharp`). Support de l'**AVIF** intégré.
- **Vidéos** : Conversion vers **WebM** (VP9/Opus via `ffmpeg`). Support de l'**AV1** implémenté.
- **Validation Stricte** :
- Limites de taille configurables (par défaut : 512 Ko pour les images, 1024 Ko pour les GIFs).
- **Gestion du Cycle de Vie** : Support du **Soft Delete** (Droit à l'oubli) et de la restauration temporaire.
### 📦 Stockage S3 (MinIO)
Pour la gestion des médias, Memegoat utilise **MinIO**, une solution de stockage d'objets compatible S3, auto-hébergée.
- **Sécurité** : Le serveur MinIO est isolé dans le réseau interne de Docker et n'est pas exposé directement sur internet.
- **Accès** : Le Backend fait office de proxy ou génère des URLs présignées pour l'accès aux fichiers, garantissant un contrôle total sur la diffusion des contenus.
- **Performance** : Optimisé pour le service rapide de fichiers volumineux comme les GIFs.
Pour la gestion des médias, Memegoat utilise **MinIO**, une solution de stockage d'objects compatible S3, auto-hébergée.
<Callout type="info" title="Isolation Réseau">
Le serveur MinIO est isolé dans le réseau interne de Docker et n'est pas exposé directement sur internet. Le Backend fait office de proxy ou génère des URLs présignées.
</Callout>
---
## 🔍 Expérience Utilisateur
### 🔍 SEO & Partage
La plateforme est optimisée pour le référencement naturel et le partage social :
- **Metatags dynamiques** : Génération de balises OpenGraph et Twitter Cards pour chaque mème.
- **Metatags dynamiques** : Le Backend détecte les robots (Twitter, Facebook, Google) et sert un rendu HTML spécifique avec les balises OpenGraph et Twitter Cards.
- **URLs Sémantiques** : Chaque mème possède un slug unique généré à partir de son titre (ex: `memegoat.fr/contents/mon-super-meme`).
- **Indexation** : Structure sémantique HTML5 et Sitemap dynamique.
- **Rendus côté serveur (SSR)** : Utilisation de Next.js pour un affichage instantané et une indexation parfaite par les robots.
- **Rendus côté serveur (SSR)** : Utilisation de Next.js pour un affichage instantané pour les utilisateurs.
### 🔗 URLs de Terminaison
À l'instar de plateformes comme Tenor, Memegoat utilise des structures d'URLs courtes et sémantiques :
- Format : `memegoat.fr/m/[slug-unique]` ou `memegoat.fr/g/[slug-unique]`.
- Les slugs sont générés de manière à être lisibles par l'humain tout en garantissant l'unicité.
### ⚡ Performance & Cache
Pour garantir une réactivité maximale, Memegoat utilise plusieurs niveaux de cache :
- **Cache Redis** : Les résultats des requêtes fréquentes (tendances, exploration) sont stockés dans un cache Redis côté serveur.
- **Directives HTTP** : Utilisation rigoureuse des headers `Cache-Control` pour permettre la mise en cache par les navigateurs et les CDNs.
---
## 🛡️ Gouvernance & Sécurité
### 🕵️ Audit des Actions
Chaque action sensible sur la plateforme est tracée dans la table `audit_logs` :
- Modification de profil, suppression de contenu, changements de permissions.
- Enregistrement de l'auteur, de l'action, de l'horodatage et des détails techniques (IP hachée, User-Agent).
- Outil essentiel pour la sécurité et la conformité RGPD.
### 👤 Gestion du Profil
Un système complet de gestion de profil permet aux utilisateurs de :
- Gérer leurs informations personnelles (nom d'affichage, avatar).
- Configurer la **Double Authentification (2FA)**.
- Consulter leurs sessions actives et révoquer des accès.
- Les données sensibles sont protégées par **chiffrement PGP** au repos.
### 🚩 Gestion des Signalements
<Callout type="info">
Toutes les données sensibles du profil sont protégées par **chiffrement PGP** au repos.
</Callout>
### 🚩 Modération & Signalements
Un système de modération intégré permet de maintenir la qualité du contenu :
- Signalement de contenus (mèmes, GIFs) ou de tags inappropriés.
- Workflow de traitement : `pending` -> `reviewed` -> `resolved` / `dismissed`.
- Signalement de contenus ou de tags inappropriés.
- Workflow : `pending` -> `reviewed` -> `resolved` / `dismissed`.
- Purge automatique des signalements obsolètes pour respecter la minimisation des données (RGPD).
### 📤 Publication de Contenu
Le coeur de la plateforme permet la publication de mèmes et de GIFs :
- Support des formats images standards et animés.
- Système de **Tags** pour catégoriser et faciliter la recherche.
- Gestion du cycle de vie des contenus (Publication, Edition, Soft Delete).

View File

@@ -5,15 +5,13 @@ description: Détails techniques du projet Memegoat
# 🐐 Détails Techniques - Memegoat
Ce document regroupe l'ensemble des spécifications techniques du projet Memegoat.
Bienvenue dans la documentation technique de Memegoat. Ce portail centralise toutes les spécifications, modèles et guides nécessaires à la compréhension et à l'évolution de la plateforme.
## 🏗️ Architecture Globale
### Vue d'ensemble
Memegoat repose sur une architecture **monorepo** moderne, garantissant une cohérence forte entre le frontend, le backend et l'infrastructure.
Description de l'architecture en monorepo et des interactions entre les services.
### Diagrammes
### Interaction des Services
```mermaid
graph TD
@@ -22,22 +20,48 @@ graph TD
Frontend[Frontend: Next.js]
Backend[Backend: NestJS]
DB[(Database: PostgreSQL)]
Storage[Storage: S3 Compatible]
Storage[Storage: S3/MinIO]
Cache[(Cache: Redis)]
Monitoring[Monitoring: Sentry]
User <--> Caddy
Caddy <--> Frontend
Caddy <--> Backend
Backend <--> DB
Backend <--> Storage
Backend <--> Cache
Backend --> Monitoring
```
### Navigation
### Navigation Rapide
Consultez les différentes sections pour plus de détails :
- [Fonctionnalités Techniques](/docs/features)
- [Stack Technologique](/docs/stack)
- [Modèle de Données](/docs/database)
- [Sécurité](/docs/security)
- [Conformité RGPD](/docs/compliance)
- [API & Intégrations](/docs/api)
- [Déploiement](/docs/deployment)
Explorez les sections clés pour approfondir vos connaissances techniques :
<Cards>
<Card
title="🚀 Fonctionnalités"
href="/docs/features"
description="Détails des capacités techniques et du pipeline média haute performance."
/>
<Card
title="🔐 Sécurité"
href="/docs/security"
description="Chiffrement PGP natif, Argon2id, RBAC et protection proactive ClamAV."
/>
<Card
title="⚖️ Conformité"
href="/docs/compliance"
description="Mise en œuvre du RGPD, droit à l'oubli et portabilité des données."
/>
<Card
title="📖 Référence API"
href="/docs/api-reference"
description="Documentation exhaustive de tous les points de terminaison de l'API."
/>
</Cards>
---
<Callout type="info">
Cette documentation est destinée aux développeurs et aux administrateurs système. Pour toute question sur l'utilisation du site, merci de consulter l'aide en ligne sur [memegoat.fr](https://memegoat.fr).
</Callout>

View File

@@ -7,7 +7,12 @@ description: Mesures de sécurité implémentées
### Protection des Données (At Rest)
- **Chiffrement PGP Natif** : Les données identifiantes (PII) comme l'email, le nom d'affichage et le **secret 2FA** sont chiffrées dans PostgreSQL via `pgcrypto` (`pgp_sym_encrypt`). Les clés de déchiffrement ne sont jamais stockées en base de données.
- **Chiffrement PGP Natif** : Les données identifiantes (PII) comme l'email, le nom d'affichage et le **secret 2FA** sont chiffrées dans PostgreSQL via `pgcrypto` (`pgp_sym_encrypt`).
<Callout type="warn" title="Sécurité des Clés">
Les clés de déchiffrement ne sont jamais stockées en base de données. Elles sont injectées via les variables d'environnement au démarrage du service.
</Callout>
- **Hachage aveugle (Blind Indexing)** : Pour permettre la recherche et l'unicité sur les données chiffrées (comme l'email), un hash non réversible (SHA-256) est stocké séparément (`email_hash`).
- **Hachage des mots de passe** : Utilisation d'**Argon2id** (via `@node-rs/argon2`), configuré selon les recommandations de l'ANSSI pour résister aux attaques par force brute et par table de correspondance.
@@ -15,12 +20,18 @@ description: Mesures de sécurité implémentées
- **TLS 1.3** : Assuré par le reverse proxy **Caddy** avec renouvellement automatique des certificats Let's Encrypt.
- **Protocoles d'Authentification** :
- **Sessions (JWT)** : Les jetons de rafraîchissement (`refresh_token`) sont stockés de manière sécurisée en base de données. L'IP de l'utilisateur est hachée (`ip_hash`) pour concilier sécurité et respect de la vie privée.
- **Sessions (iron-session)** : Utilisation de cookies sécurisés, `HttpOnly`, `Secure` et `SameSite: Strict`. Les tokens (JWT) sont stockés dans ces cookies chiffrés côté serveur, empêchant tout accès via JavaScript (XSS).
- **API Keys** : Les clés API sont hachées en base de données (**SHA-256**) via la colonne `key_hash`. Seul un préfixe est conservé en clair pour l'identification.
### Infrastructure & Défense
### Infrastructure & Surveillance
- **Rate Limiting** : Protection contre le brute-force et le déni de service (DoS).
- **CORS Policy** : Restriction stricte des origines autorisées.
- **RBAC (Role Based Access Control)** : Gestion granulaire des permissions avec une structure complète de rôles et de permissions liées (`roles`, `permissions`, `roles_to_permissions`).
<Cards>
<Card title="Antivirus (ClamAV)" description="Scan systématique de tous les fichiers entrants avant stockage." />
<Card title="Sentry" description="Suivi des erreurs en temps réel avec respect strict du RGPD." />
<Card title="Rate Limiting" description="Protection contre le brute-force et le DoS via NestJS Throttler." />
<Card title="RBAC" description="Contrôle d'accès granulaire basé sur les rôles et permissions." />
</Cards>
- **CORS Policy** : Restriction stricte des origines autorisées, configurée dynamiquement selon l'environnement.
- **Security Headers** : Utilisation de `helmet` pour activer les protections standards des navigateurs (XSS, Clickjacking, etc.).
- **Audit Logs** : Traçabilité complète des actions sensibles via la table `audit_logs`. Elle enregistre l'action, l'entité concernée, les détails au format JSONB, ainsi que l'IP hachée et le User-Agent pour l'imputabilité.

View File

@@ -7,22 +7,39 @@ description: Technologies utilisées dans le projet Memegoat
### Frontend
- **Framework** : NextJS
- **Gestion d'état** : Zustand
- **Style** : Tailwind CSS
- **Composants UI** : Shadcn/ui
<Cards>
<Card title="NextJS" description="Framework React pour le SSR et la performance." />
<Card title="Tailwind CSS" description="Design système utilitaire pour le styling." />
<Card title="Zustand" description="Gestion d'état légère et performante." />
<Card title="Shadcn/ui" description="Composants UI accessibles et personnalisables." />
</Cards>
### Backend
- **Framework** : NestJS
- **Langage** : TypeScript
- **Base de données** : PostgresQL
- **ORM** : DrizzleORM
<Cards>
<Card title="NestJS" description="Framework Node.js modulaire et robuste." />
<Card title="PostgreSQL" description="Base de données relationnelle puissante." />
<Card title="Redis" description="Store clé-valeur pour le cache haute performance." />
<Card title="Drizzle ORM" description="ORM TypeScript-first avec support des migrations." />
<Card title="Sharp & FFmpeg" description="Traitement haute performance des images et vidéos." />
</Cards>
### Sécurité & Monitoring
<Cards>
<Card title="ClamAV" description="Protection antivirus en temps réel." />
<Card title="Sentry" description="Reporting d'erreurs et profiling de performance." />
<Card title="Argon2id" description="Hachage de mots de passe de grade militaire." />
<Card title="PGP (pgcrypto)" description="Chiffrement natif des données sensibles." />
<Card title="otplib" description="Implémentation TOTP pour la 2FA." />
<Card title="iron-session" description="Gestion sécurisée des sessions via cookies chiffrés." />
</Cards>
### Infrastructure & DevOps
- **Conteneurisation** : Docker / Docker Compose
- **Reverse Proxy & TLS** : Caddy
- **Stockage d'objets** : MinIO (compatible S3)
- **CI/CD** : Gitea Actions
- **Hébergement** : Hetzner Dedicated Server
<Cards>
<Card title="Docker" description="Conteneurisation et orchestration (Compose)." />
<Card title="Caddy" description="Reverse proxy moderne avec TLS automatique." />
<Card title="MinIO" description="Stockage d'objets auto-hébergé compatible S3." />
<Card title="Hetzner" description="Hébergement sur serveurs dédiés en Europe." />
</Cards>

View File

@@ -4,7 +4,8 @@ const withMDX = createMDX();
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
reactStrictMode: true,
output: 'standalone',
};
export default withMDX(config);

View File

@@ -1,5 +1,5 @@
{
"name": "@bypass/documentation",
"name": "@memegoat/documentation",
"version": "0.0.1",
"private": true,
"scripts": {

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="48"
height="48"
viewBox="0 0 12.7 12.7"
version="1.1"
id="svg1"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
><g
id="layer2"
style="display:inline;fill:#331515;fill-opacity:1;stroke:none;stroke-opacity:1"><g
id="g4"
transform="matrix(0.27373039,0,0,0.2517503,-17.319768,-6.635693)"><path
style="fill:#f2f2f2;fill-opacity:1;stroke:none;stroke-width:0.264583;stroke-opacity:1"
d="m 63.549508,31.272583 c 14.328936,-3.710301 27.209984,13.389088 27.890078,13.535152 0.680094,0.146064 1.97586,-0.235884 2.525697,-0.05846 0.549838,0.177421 15.026777,16.527845 15.060637,17.083533 1.74538,3.30074 -1.90259,3.06506 -3.92583,2.323248 5.37774,-0.791177 -2.21688,-5.198834 -11.742846,-16.869391 -0.147456,-0.175131 -3.964059,0.778259 -4.256268,0.701582 C 88.808767,47.911567 81.73125,33.52768 63.549508,31.272587 Z"
id="path3"
/><path
style="fill:#f2f2f2;fill-opacity:1;stroke:none;stroke-width:0.264583;stroke-opacity:1"
d="m 93.034113,43.424739 c 0.01364,0.09232 -0.675113,0.182763 -0.888834,0.115759 -0.629692,-0.633297 -8.246274,-12.981478 -21.468457,-16.867187 1.693703,0.215332 13.413267,-2.089805 22.357291,16.751428 z"
id="path4"
/><path
style="fill:#331515;fill-opacity:1;stroke:none;stroke-width:0.264583;stroke-opacity:1"
d="m 67.93177,57.811458 c 0,0 8.027633,-6.50087 12.881414,-6.79531 0.480584,-0.387983 -3.871691,-1.542721 -2.380562,-3.24086 1.49113,-1.698139 5.250473,0.540425 7.391596,1.999451 1.766946,1.505799 3.023678,1.996756 4.505942,2.316766 1.482263,0.320011 4.02687,-0.200099 4.02687,-0.200099 0,0 1.940728,0.98341 4.540677,5.004385 -2.591761,-0.7264 -2.41512,-0.981648 -3.208762,-1.57919 -2.106185,-1.512522 -2.069041,-1.140598 -3.382436,-1.176237 -1.313395,-0.03564 -2.736712,1.308881 -4.134114,1.190625 -1.397402,-0.118256 -3.077124,-1.180017 -3.770313,-1.587501 -3.167354,-1.124112 -16.470312,4.06797 -16.470312,4.06797 z"
id="path2"
/><path
style="fill:#331515;fill-opacity:1;stroke:none;stroke-width:0.264583;stroke-opacity:1"
d="m 73.743605,76.275575 c 2.018029,-13.473637 15.69821,-18.736572 15.69821,-18.736572 0,0 -1.204881,6.06481 3.98785,6.266622 1.334027,0.15504 6.078103,-0.608039 12.805785,3.325856 -1.83163,2.367028 -2.13487,-0.599513 -3.84316,0.01934 -1.17041,1.0398 1.90501,6.011329 -3.043981,9.45497 C 102.13838,64.571303 84.706376,58.089029 73.743605,76.275575 Z"
id="path1"
/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -1,16 +1,168 @@
import Link from "next/link";
import { Shield, Scale, Zap, Database, Server, Code, ArrowRight } from "lucide-react";
export default function HomePage() {
return (
<div className="flex flex-col justify-center text-center flex-1">
<h1 className="text-2xl font-bold mb-4">Hello World</h1>
<p>
You can open{" "}
<Link href="/docs" className="font-medium underline">
/docs
</Link>{" "}
and see the documentation.
</p>
</div>
<main className="flex flex-1 flex-col">
{/* Hero Section */}
<section className="relative overflow-hidden py-24 sm:py-32">
<div className="absolute top-0 left-1/2 -z-10 h-256 w-512 -translate-x-1/2 mask-[radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-[-10%]">
<svg
viewBox="0 0 1108 632"
aria-hidden="true"
className="w-277 fill-primary/10"
>
<title>Decorative background</title>
<path d="M410.151 513.506L299.302 153.332L0 443.517L410.151 513.506Z" />
<path d="M1108 138.337L817.11 0L748.5 549.691L1108 138.337Z" />
</svg>
</div>
<div className="container px-6 lg:px-8 mx-auto">
<div className="mx-auto max-w-3xl text-center">
<div className="mb-8 flex justify-center">
<div className="relative rounded-full px-4 py-1.5 text-sm leading-6 text-muted-foreground ring-1 ring-border hover:ring-accent transition-colors bg-card/50 backdrop-blur-sm">
Propulsé par une architecture sécurisée{" "}
<Link href="/docs/security" className="font-semibold text-primary">
<span className="absolute inset-0" aria-hidden="true" />
En savoir plus <span aria-hidden="true">&rarr;</span>
</Link>
</div>
</div>
<h1 className="text-5xl font-extrabold tracking-tight sm:text-7xl mb-8 bg-linear-to-r from-primary via-orange-500 to-amber-500 bg-clip-text text-transparent leading-tight">
La Bible Technique de MemeGoat 🐐
</h1>
<p className="mt-6 text-xl leading-8 text-muted-foreground">
Parce que partager des MEME de qualité demande une infrastructure qui ne broute pas.
Découvrez comment nous avons bâti le futur du rire avec une pointe de sérieux (mais pas trop).
</p>
<div className="mt-12 flex flex-col sm:flex-row items-center justify-center gap-4 sm:gap-x-6">
<Link
href="/docs"
className="w-full sm:w-auto rounded-xl bg-primary px-8 py-4 text-base font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all transform hover:scale-105 active:scale-95"
>
Débuter l'exploration
</Link>
<Link
href="/docs/api-reference"
className="w-full sm:w-auto text-base font-semibold leading-6 text-foreground hover:text-primary transition-colors flex items-center justify-center gap-2 py-4 sm:py-0"
>
Référence API <ArrowRight className="h-5 w-5" />
</Link>
</div>
</div>
</div>
</section>
{/* Pillars Section */}
<section className="py-24 bg-fd-background/50 border-y border-border">
<div className="container px-6 lg:px-8 mx-auto">
<div className="mx-auto max-w-2xl lg:text-center mb-16">
<h2 className="text-base font-bold leading-7 text-primary uppercase tracking-widest">Les Fondations</h2>
<p className="mt-2 text-4xl font-extrabold tracking-tight text-foreground sm:text-5xl">
Une plateforme bâtie pour tenir
</p>
</div>
<div className="mx-auto mt-16 max-w-2xl sm:mt-20 lg:mt-24 lg:max-w-none">
<dl className="grid max-w-xl grid-cols-1 gap-x-8 gap-y-12 lg:max-w-none lg:grid-cols-3">
{[
{
name: "Coffre-Fort Bêêêê-ton",
description: "Chiffrement PGP au repos et hachage Argon2id. Vos données sont plus en sécurité ici que dans un enclos fermé à double tour.",
icon: Shield,
},
{
name: "RGPD & Relax",
description: "Droit à l'oubli et portabilité. On respecte votre vie privée autant que vous respectez un bon mème bien placé.",
icon: Scale,
},
{
name: "Vitesse Turbo-Chèvre",
description: "Transcodage WebP/WebM instantané. Vos GIFs se chargent plus vite que votre ombre, même sur le vieux smartphone de mamie.",
icon: Zap,
},
].map((feature) => (
<div key={feature.name} className="flex flex-col border border-border p-10 rounded-3xl bg-card hover:border-primary/50 transition-all shadow-md hover:shadow-xl group">
<dt className="flex items-center gap-x-4 text-xl font-bold leading-7 text-foreground">
<div className="p-3 rounded-xl bg-primary/10 group-hover:bg-primary group-hover:text-primary-foreground transition-colors">
<feature.icon className="h-6 w-6" aria-hidden="true" />
</div>
{feature.name}
</dt>
<dd className="mt-6 flex flex-auto flex-col text-base leading-7 text-muted-foreground">
<p className="flex-auto">{feature.description}</p>
</dd>
</div>
))}
</dl>
</div>
</div>
</section>
{/* Tech Stack Section */}
<section className="py-24 sm:py-32 overflow-hidden">
<div className="container px-6 lg:px-8 mx-auto">
<div className="mx-auto max-w-2xl lg:text-center mb-20">
<h2 className="text-base font-bold leading-7 text-primary uppercase tracking-widest text-center">Stack Technique</h2>
<p className="mt-2 text-4xl font-extrabold tracking-tight text-foreground sm:text-5xl text-center">
Technologies de pointe
</p>
</div>
<div className="mx-auto max-w-5xl">
<div className="grid grid-cols-2 gap-6 md:grid-cols-4">
{[
{ name: "Next.js 16", icon: Code, color: "hover:text-black dark:hover:text-white" },
{ name: "NestJS 11", icon: Server, color: "hover:text-red-500" },
{ name: "PostgreSQL 15", icon: Database, color: "hover:text-blue-500" },
{ name: "Chèvre-Power", icon: Zap, color: "hover:text-orange-500" },
].map((tech) => (
<div key={tech.name} className="flex flex-col items-center p-8 border border-border rounded-2xl bg-card/50 hover:bg-card hover:border-primary transition-all group">
<tech.icon className={`h-12 w-12 text-muted-foreground mb-4 transition-colors ${tech.color}`} />
<span className="font-bold text-lg">{tech.name}</span>
</div>
))}
</div>
</div>
</div>
</section>
{/* Quick Access Section */}
<section className="py-24 bg-card border-t border-border">
<div className="container px-6 lg:px-8 mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
<div>
<h2 className="text-3xl font-extrabold mb-6 italic text-primary">"On ne rigole pas avec le rire. Surtout quand il s'agit de vous." 🐐</h2>
<p className="text-lg text-muted-foreground mb-8">
Memegoat n'est pas qu'un site pour scroller à l'infini. C'est un site qui as pour but de partager des MEME de qualité sans surconsommer les ressources mondiales avec des fichiers beaucoup trop gros et des requêtes trop nombreuses.
</p>
<Link
href="/docs/index"
className="inline-flex items-center text-primary font-bold hover:gap-3 transition-all gap-2 text-lg"
>
Plongez dans le code (garanti sans crottin) <ArrowRight className="h-6 w-6" />
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Link href="/docs/database" className="p-6 border border-border rounded-2xl hover:bg-accent transition-colors font-bold flex flex-col gap-2">
<Database className="h-6 w-6 text-primary" />
Modèle de Données
</Link>
<Link href="/docs/security" className="p-6 border border-border rounded-2xl hover:bg-accent transition-colors font-bold flex flex-col gap-2">
<Shield className="h-6 w-6 text-primary" />
Sécurité PGP
</Link>
<Link href="/docs/api-reference" className="p-6 border border-border rounded-2xl hover:bg-accent transition-colors font-bold flex flex-col gap-2">
<Code className="h-6 w-6 text-primary" />
Référence API
</Link>
<Link href="/docs/deployment" className="p-6 border border-border rounded-2xl hover:bg-accent transition-colors font-bold flex flex-col gap-2">
<Server className="h-6 w-6 text-primary" />
Déploiement
</Link>
</div>
</div>
</div>
</section>
</main>
);
}

View File

@@ -3,5 +3,5 @@ import { source } from "@/lib/source";
export const { GET } = createFromSource(source, {
// https://docs.orama.com/docs/orama-js/supported-languages
language: "english",
language: "french",
});

View File

@@ -1,3 +1,3 @@
@import "tailwindcss";
@import "fumadocs-ui/css/neutral.css";
@import "fumadocs-ui/css/catppuccin.css";
@import "fumadocs-ui/css/preset.css";

View File

@@ -6,9 +6,17 @@ const inter = Inter({
subsets: ["latin"],
});
export const metadata = {
title: {
template: "%s | Memegoat Docs",
default: "Memegoat Documentation",
},
description: "Documentation technique officielle de Memegoat.",
};
export default function Layout({ children }: LayoutProps<"/">) {
return (
<html lang="en" className={inter.className} suppressHydrationWarning>
<html lang="fr" className={inter.className} suppressHydrationWarning>
<body className="flex flex-col min-h-screen">
<RootProvider>{children}</RootProvider>
</body>

View File

@@ -1,9 +1,32 @@
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
import {Image} from "fumadocs-core/framework";
export function baseOptions(): BaseLayoutProps {
return {
nav: {
title: "My App",
title: (
<span className="flex items-center gap-2 font-bold text-xl">
<Image
src="/memegoat-color.svg"
alt="Memegoat Logo"
width={64}
height={64}
className="w-8 h-8"
/>
<span>Memegoat</span>
</span>
),
},
links: [
{
text: "Documentation",
url: "/docs",
active: "nested-url",
},
{
text: "GitHub",
url: "https://git.yidhra.fr/Mathis/memegoat",
},
],
};
}

View File

@@ -1,11 +1,25 @@
import defaultMdxComponents from "fumadocs-ui/mdx";
import type { MDXComponents } from "mdx/types";
import { Mermaid } from "@/components/mdx/mermaid";
import { Card, Cards } from "fumadocs-ui/components/card";
import { Callout } from "fumadocs-ui/components/callout";
import { Step, Steps } from "fumadocs-ui/components/steps";
import { Tabs, Tab } from "fumadocs-ui/components/tabs";
import { Accordion, Accordions } from "fumadocs-ui/components/accordion";
export function getMDXComponents(components?: MDXComponents): MDXComponents {
return {
...defaultMdxComponents,
Mermaid,
Card,
Cards,
Callout,
Step,
Steps,
Tabs,
Tab,
Accordion,
Accordions,
...components,
};
}

22
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:22-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS build
WORKDIR /usr/src/app
COPY . .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run --filter @memegoat/frontend build
FROM base AS runtime
WORKDIR /app
COPY --from=build /usr/src/app/frontend/public ./frontend/public
COPY --from=build /usr/src/app/frontend/.next/standalone ./
COPY --from=build /usr/src/app/frontend/.next/static ./frontend/.next/static
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "frontend/server.js"]

View File

@@ -1,8 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
/* config options here */
reactCompiler: true,
output: 'standalone',
};
export default nextConfig;

View File

@@ -17,7 +17,7 @@
"format": "pnpm run format:back && pnpm run format:front && pnpm run format:docs",
"format:back": "pnpm run -F @memegoat/backend format",
"format:front": "pnpm run -F @memegoat/frontend format",
"format:docs": "pnpm run -F @memegoat/documentation format,",
"format:docs": "pnpm run -F @memegoat/documentation format",
"upgrade": "pnpm dlx taze minor"
},
"keywords": [],

1428
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff