Compare commits
33 Commits
9ab737b8c7
...
cc2823db7d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc2823db7d
|
||
|
|
6254c136d1
|
||
|
|
3828f170e2
|
||
|
|
ec771eb074
|
||
|
|
77263aead9
|
||
|
|
ab74dc3b30
|
||
|
|
acd53eff6a
|
||
|
|
91e23c2c02
|
||
|
|
f508e8ee6d
|
||
|
|
3c02bd6023
|
||
|
|
6e823743fc
|
||
|
|
99a350aa05
|
||
|
|
8b51b84d44
|
||
|
|
0af6f6b52a
|
||
|
|
382e39ebd0
|
||
|
|
65b7cba6b1
|
||
|
|
f7d85108e1
|
||
|
|
d5775a821e
|
||
|
|
add7cab7df
|
||
|
|
da5f18bf92
|
||
|
|
a0836c8392
|
||
|
|
9963046e41
|
||
|
|
dde1bf522f
|
||
|
|
dd875fe1ea
|
||
|
|
92ea36545a
|
||
|
|
912394477b
|
||
|
|
fe309bc1e3
|
||
|
|
342e9b99da
|
||
|
|
e210f1f95f
|
||
|
|
2218768adb
|
||
|
|
705f1ad6e0
|
||
|
|
42805e371e
|
||
|
|
9406ed9350
|
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
.next
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
@@ -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
|
||||
|
||||
@@ -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
17
backend/Dockerfile
Normal 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" ]
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
42
backend/src/api-keys/api-keys.controller.ts
Normal file
42
backend/src/api-keys/api-keys.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
backend/src/api-keys/api-keys.module.ts
Normal file
14
backend/src/api-keys/api-keys.module.ts
Normal 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 {}
|
||||
79
backend/src/api-keys/api-keys.service.ts
Normal file
79
backend/src/api-keys/api-keys.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
119
backend/src/auth/auth.controller.ts
Normal file
119
backend/src/auth/auth.controller.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
16
backend/src/auth/auth.module.ts
Normal file
16
backend/src/auth/auth.module.ts
Normal 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 {}
|
||||
197
backend/src/auth/auth.service.ts
Normal file
197
backend/src/auth/auth.service.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
3
backend/src/auth/decorators/roles.decorator.ts
Normal file
3
backend/src/auth/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
export const Roles = (...roles: string[]) => SetMetadata("roles", roles);
|
||||
10
backend/src/auth/dto/login.dto.ts
Normal file
10
backend/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password!: string;
|
||||
}
|
||||
7
backend/src/auth/dto/refresh.dto.ts
Normal file
7
backend/src/auth/dto/refresh.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
export class RefreshDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
refresh_token!: string;
|
||||
}
|
||||
14
backend/src/auth/dto/register.dto.ts
Normal file
14
backend/src/auth/dto/register.dto.ts
Normal 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;
|
||||
}
|
||||
10
backend/src/auth/dto/verify-2fa.dto.ts
Normal file
10
backend/src/auth/dto/verify-2fa.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsNotEmpty, IsString, IsUUID } from "class-validator";
|
||||
|
||||
export class Verify2faDto {
|
||||
@IsUUID()
|
||||
userId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
token!: string;
|
||||
}
|
||||
44
backend/src/auth/guards/auth.guard.ts
Normal file
44
backend/src/auth/guards/auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
28
backend/src/auth/guards/roles.guard.ts
Normal file
28
backend/src/auth/guards/roles.guard.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
42
backend/src/auth/rbac.service.ts
Normal file
42
backend/src/auth/rbac.service.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
18
backend/src/auth/session.config.ts
Normal file
18
backend/src/auth/session.config.ts
Normal 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
|
||||
},
|
||||
});
|
||||
43
backend/src/categories/categories.controller.ts
Normal file
43
backend/src/categories/categories.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
backend/src/categories/categories.module.ts
Normal file
12
backend/src/categories/categories.module.ts
Normal 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 {}
|
||||
64
backend/src/categories/categories.service.ts
Normal file
64
backend/src/categories/categories.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
15
backend/src/categories/dto/create-category.dto.ts
Normal file
15
backend/src/categories/dto/create-category.dto.ts
Normal 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;
|
||||
}
|
||||
4
backend/src/categories/dto/update-category.dto.ts
Normal file
4
backend/src/categories/dto/update-category.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from "@nestjs/mapped-types";
|
||||
import { CreateCategoryDto } from "./create-category.dto";
|
||||
|
||||
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}
|
||||
11
backend/src/common/common.module.ts
Normal file
11
backend/src/common/common.module.ts
Normal 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 {}
|
||||
56
backend/src/common/filters/http-exception.filter.ts
Normal file
56
backend/src/common/filters/http-exception.filter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
8
backend/src/common/interfaces/request.interface.ts
Normal file
8
backend/src/common/interfaces/request.interface.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Request } from "express";
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user: {
|
||||
sub: string;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
63
backend/src/common/services/purge.service.ts
Normal file
63
backend/src/common/services/purge.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
63
backend/src/config/env.schema.ts
Normal file
63
backend/src/config/env.schema.ts
Normal 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;
|
||||
}
|
||||
179
backend/src/contents/contents.controller.ts
Normal file
179
backend/src/contents/contents.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
15
backend/src/contents/contents.module.ts
Normal file
15
backend/src/contents/contents.module.ts
Normal 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 {}
|
||||
349
backend/src/contents/contents.service.ts
Normal file
349
backend/src/contents/contents.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
43
backend/src/contents/dto/create-content.dto.ts
Normal file
43
backend/src/contents/dto/create-content.dto.ts
Normal 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[];
|
||||
}
|
||||
25
backend/src/contents/dto/upload-content.dto.ts
Normal file
25
backend/src/contents/dto/upload-content.dto.ts
Normal 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[];
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
24
backend/src/database/schemas/categories.ts
Normal file
24
backend/src/database/schemas/categories.ts
Normal 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;
|
||||
@@ -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(),
|
||||
|
||||
24
backend/src/database/schemas/favorites.ts
Normal file
24
backend/src/database/schemas/favorites.ts
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
43
backend/src/favorites/favorites.controller.ts
Normal file
43
backend/src/favorites/favorites.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
backend/src/favorites/favorites.module.ts
Normal file
12
backend/src/favorites/favorites.module.ts
Normal 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 {}
|
||||
63
backend/src/favorites/favorites.service.ts
Normal file
63
backend/src/favorites/favorites.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
28
backend/src/health.controller.ts
Normal file
28
backend/src/health.controller.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
13
backend/src/media/interfaces/media.interface.ts
Normal file
13
backend/src/media/interfaces/media.interface.ts
Normal 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;
|
||||
}
|
||||
8
backend/src/media/media.module.ts
Normal file
8
backend/src/media/media.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { MediaService } from "./media.service";
|
||||
|
||||
@Module({
|
||||
providers: [MediaService],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
166
backend/src/media/media.service.ts
Normal file
166
backend/src/media/media.service.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
25
backend/src/reports/dto/create-report.dto.ts
Normal file
25
backend/src/reports/dto/create-report.dto.ts
Normal 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;
|
||||
}
|
||||
13
backend/src/reports/dto/update-report-status.dto.ts
Normal file
13
backend/src/reports/dto/update-report-status.dto.ts
Normal 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";
|
||||
}
|
||||
54
backend/src/reports/reports.controller.ts
Normal file
54
backend/src/reports/reports.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
backend/src/reports/reports.module.ts
Normal file
13
backend/src/reports/reports.module.ts
Normal 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 {}
|
||||
44
backend/src/reports/reports.service.ts
Normal file
44
backend/src/reports/reports.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
11
backend/src/sessions/sessions.module.ts
Normal file
11
backend/src/sessions/sessions.module.ts
Normal 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 {}
|
||||
88
backend/src/sessions/sessions.service.ts
Normal file
88
backend/src/sessions/sessions.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
23
backend/src/tags/tags.controller.ts
Normal file
23
backend/src/tags/tags.controller.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
12
backend/src/tags/tags.module.ts
Normal file
12
backend/src/tags/tags.module.ts
Normal 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 {}
|
||||
53
backend/src/tags/tags.service.ts
Normal file
53
backend/src/tags/tags.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
11
backend/src/users/dto/update-consent.dto.ts
Normal file
11
backend/src/users/dto/update-consent.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
export class UpdateConsentDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
termsVersion!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
privacyVersion!: string;
|
||||
}
|
||||
8
backend/src/users/dto/update-user.dto.ts
Normal file
8
backend/src/users/dto/update-user.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
displayName?: string;
|
||||
}
|
||||
107
backend/src/users/users.controller.ts
Normal file
107
backend/src/users/users.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
backend/src/users/users.module.ts
Normal file
14
backend/src/users/users.module.ts
Normal 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 {}
|
||||
224
backend/src/users/users.service.ts
Normal file
224
backend/src/users/users.service.ts
Normal 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
22
documentation/Dockerfile
Normal 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"]
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
191
documentation/content/docs/api-reference.mdx
Normal file
191
documentation/content/docs/api-reference.mdx
Normal 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>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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é.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,7 +4,8 @@ const withMDX = createMDX();
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
reactStrictMode: true,
|
||||
reactStrictMode: true,
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default withMDX(config);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@bypass/documentation",
|
||||
"name": "@memegoat/documentation",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
32
documentation/public/memegoat-color.svg
Normal file
32
documentation/public/memegoat-color.svg
Normal 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 |
@@ -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">→</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
@import "tailwindcss";
|
||||
@import "fumadocs-ui/css/neutral.css";
|
||||
@import "fumadocs-ui/css/catppuccin.css";
|
||||
@import "fumadocs-ui/css/preset.css";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
22
frontend/Dockerfile
Normal 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"]
|
||||
@@ -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;
|
||||
|
||||
@@ -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
1428
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user