From 342e9b99da13f0f6fd0d3981088d2df232ee1498 Mon Sep 17 00:00:00 2001 From: Mathis HERRIOT <197931332+0x485254@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:25:28 +0100 Subject: [PATCH] feat: implement ContentsModule with controllers, services, and DTOs Added a new ContentsModule to handle content creation, upload, and management. Includes controller endpoints for CRUD operations, content exploration, and tagging. Integrated caching, file processing, S3 storage, and database logic. --- backend/src/contents/contents.controller.ts | 178 +++++++++ backend/src/contents/contents.module.ts | 15 + backend/src/contents/contents.service.ts | 343 ++++++++++++++++++ .../src/contents/dto/create-content.dto.ts | 43 +++ .../src/contents/dto/upload-content.dto.ts | 19 + 5 files changed, 598 insertions(+) create mode 100644 backend/src/contents/contents.controller.ts create mode 100644 backend/src/contents/contents.module.ts create mode 100644 backend/src/contents/contents.service.ts create mode 100644 backend/src/contents/dto/create-content.dto.ts create mode 100644 backend/src/contents/dto/upload-content.dto.ts 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}

+ ${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[]; +}