diff --git a/backend/src/contents/contents.controller.ts b/backend/src/contents/contents.controller.ts
new file mode 100644
index 0000000..57bb23d
--- /dev/null
+++ b/backend/src/contents/contents.controller.ts
@@ -0,0 +1,178 @@
+import {
+ Body,
+ Controller,
+ DefaultValuePipe,
+ Delete,
+ Get,
+ Header,
+ Param,
+ ParseBoolPipe,
+ ParseIntPipe,
+ Post,
+ Query,
+ Req,
+ Res,
+ UploadedFile,
+ UseGuards,
+ UseInterceptors,
+ NotFoundException,
+} from "@nestjs/common";
+import { CacheInterceptor, CacheTTL } from "@nestjs/cache-manager";
+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 = `
+
+
+
+ ${content.title}
+
+
+
+
+
+
+
+
+
+ ${content.title}
+
+
+`;
+ 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);
+ }
+}
diff --git a/backend/src/contents/contents.module.ts b/backend/src/contents/contents.module.ts
new file mode 100644
index 0000000..a782100
--- /dev/null
+++ b/backend/src/contents/contents.module.ts
@@ -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 {}
diff --git a/backend/src/contents/contents.service.ts b/backend/src/contents/contents.service.ts
new file mode 100644
index 0000000..33d0386
--- /dev/null
+++ b/backend/src/contents/contents.service.ts
@@ -0,0 +1,343 @@
+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 { MediaService } from "../media/media.service";
+import type { MediaProcessingResult } from "../media/interfaces/media.interface";
+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("MAX_GIF_SIZE_KB", 1024)
+ : this.configService.get("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`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 {
+ 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;
+ }
+}
diff --git a/backend/src/contents/dto/create-content.dto.ts b/backend/src/contents/dto/create-content.dto.ts
new file mode 100644
index 0000000..096601e
--- /dev/null
+++ b/backend/src/contents/dto/create-content.dto.ts
@@ -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[];
+}
diff --git a/backend/src/contents/dto/upload-content.dto.ts b/backend/src/contents/dto/upload-content.dto.ts
new file mode 100644
index 0000000..de39007
--- /dev/null
+++ b/backend/src/contents/dto/upload-content.dto.ts
@@ -0,0 +1,19 @@
+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[];
+}