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.
This commit is contained in:
Mathis HERRIOT
2026-01-08 15:25:28 +01:00
parent e210f1f95f
commit 342e9b99da
5 changed files with 598 additions and 0 deletions

View File

@@ -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 = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${content.title}</title>
<meta property="og:title" content="${content.title}" />
<meta property="og:type" content="website" />
<meta property="og:image" content="${imageUrl}" />
<meta property="og:description" content="Découvrez ce meme sur Memegoat" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${content.title}" />
<meta name="twitter:image" content="${imageUrl}" />
</head>
<body>
<h1>${content.title}</h1>
<img src="${imageUrl}" alt="${content.title}" />
</body>
</html>`;
return res.send(html);
}
return res.json(content);
}
@Post(":id/view")
incrementViews(@Param("id") id: string) {
return this.contentsService.incrementViews(id);
}
@Post(":id/use")
incrementUsage(@Param("id") id: string) {
return this.contentsService.incrementUsage(id);
}
@Delete(":id")
@UseGuards(AuthGuard)
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
return this.contentsService.remove(id, req.user.sub);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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[];
}