import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { BadRequestException, Inject, Injectable, Logger, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import type { Cache } from "cache-manager"; import { v4 as uuidv4 } from "uuid"; import type { IMediaService, MediaProcessingResult, } from "../common/interfaces/media.interface"; import type { IStorageService } from "../common/interfaces/storage.interface"; import { MediaService } from "../media/media.service"; import { EventsGateway } from "../realtime/events.gateway"; import { S3Service } from "../s3/s3.service"; import { CreateContentDto } from "./dto/create-content.dto"; import { UploadContentDto } from "./dto/upload-content.dto"; import { ContentsRepository } from "./repositories/contents.repository"; @Injectable() export class ContentsService { private readonly logger = new Logger(ContentsService.name); constructor( private readonly contentsRepository: ContentsRepository, @Inject(S3Service) private readonly s3Service: IStorageService, @Inject(MediaService) private readonly mediaService: IMediaService, private readonly configService: ConfigService, @Inject(CACHE_MANAGER) private cacheManager: Cache, private readonly eventsGateway: EventsGateway, ) {} private async clearContentsCache() { this.logger.log("Clearing contents cache"); await this.cacheManager.clear(); } 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: UploadContentDto, ) { this.logger.log(`Uploading and processing file for user ${userId}`); this.eventsGateway.sendToUser(userId, "upload_progress", { status: "starting", progress: 0, }); // 0. Validation du format et de la taille const allowedMimeTypes = [ "image/png", "image/jpeg", "image/webp", "image/gif", "video/webm", "video/mp4", "video/quicktime", ]; if (!allowedMimeTypes.includes(file.mimetype)) { this.eventsGateway.sendToUser(userId, "upload_progress", { status: "error", message: "Format de fichier non supporté", }); throw new BadRequestException( "Format de fichier non supporté. Formats acceptés: png, jpeg, jpg, webp, webm, mp4, mov, gif.", ); } // Autodétermination du type si non fourni ou pour valider let contentType: "meme" | "gif" | "video" = "meme"; if (file.mimetype === "image/gif") { contentType = "gif"; } else if (file.mimetype.startsWith("video/")) { contentType = "video"; } const isGif = contentType === "gif"; const isVideo = contentType === "video"; let maxSizeKb: number; if (isGif) { maxSizeKb = this.configService.get("MAX_GIF_SIZE_KB", 1024); } else if (isVideo) { maxSizeKb = this.configService.get("MAX_VIDEO_SIZE_KB", 10240); } else { maxSizeKb = this.configService.get("MAX_IMAGE_SIZE_KB", 512); } if (file.size > maxSizeKb * 1024) { this.eventsGateway.sendToUser(userId, "upload_progress", { status: "error", message: "Fichier trop volumineux", }); throw new BadRequestException( `Fichier trop volumineux. Limite pour ${isGif ? "GIF" : isVideo ? "vidéo" : "image"}: ${maxSizeKb} Ko.`, ); } // 1. Scan Antivirus this.eventsGateway.sendToUser(userId, "upload_progress", { status: "scanning", progress: 20, }); const scanResult = await this.mediaService.scanFile( file.buffer, file.originalname, ); if (scanResult.isInfected) { this.eventsGateway.sendToUser(userId, "upload_progress", { status: "error", message: "Fichier infecté", }); throw new BadRequestException( `Le fichier est infecté par ${scanResult.virusName}`, ); } // 2. Transcodage this.eventsGateway.sendToUser(userId, "upload_progress", { status: "processing", progress: 40, }); let processed: MediaProcessingResult; if (file.mimetype.startsWith("image/") && file.mimetype !== "image/gif") { // Image -> WebP (format moderne, bien supporté) processed = await this.mediaService.processImage(file.buffer, "webp"); } else if ( file.mimetype.startsWith("video/") || file.mimetype === "image/gif" ) { // Vidéo ou GIF -> WebM processed = await this.mediaService.processVideo(file.buffer, "webm"); } else { throw new BadRequestException("Format de fichier non supporté"); } // 3. Upload vers S3 this.eventsGateway.sendToUser(userId, "upload_progress", { status: "uploading_s3", progress: 70, }); const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`; await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType); this.logger.log(`File uploaded successfully to S3: ${key}`); // 4. Création en base de données this.eventsGateway.sendToUser(userId, "upload_progress", { status: "saving", progress: 90, }); const content = await this.create(userId, { ...data, type: contentType, // Utiliser le type autodéterminé storageKey: key, mimeType: processed.mimeType, fileSize: processed.size, }); this.eventsGateway.sendToUser(userId, "upload_progress", { status: "completed", progress: 100, contentId: content.id, }); return content; } 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 [data, totalCount] = await Promise.all([ this.contentsRepository.findAll(options), this.contentsRepository.count(options), ]); const processedData = data.map((content) => ({ ...content, url: this.s3Service.getPublicUrl(content.storageKey), author: { ...content.author, avatarUrl: content.author?.avatarUrl ? this.s3Service.getPublicUrl(content.author.avatarUrl) : null, }, })); return { data: processedData, totalCount }; } async create(userId: string, data: CreateContentDto) { this.logger.log(`Creating content for user ${userId}: ${data.title}`); const { tags: tagNames, ...contentData } = data; const slug = await this.ensureUniqueSlug(contentData.title); const newContent = await this.contentsRepository.create( { ...contentData, userId, slug }, tagNames, ); await this.clearContentsCache(); return newContent; } async incrementViews(id: string) { return await this.contentsRepository.incrementViews(id); } async incrementUsage(id: string) { return await this.contentsRepository.incrementUsage(id); } async remove(id: string, userId: string) { this.logger.log(`Removing content ${id} for user ${userId}`); const deleted = await this.contentsRepository.softDelete(id, userId); if (deleted) { await this.clearContentsCache(); } return deleted; } async removeAdmin(id: string) { this.logger.log(`Removing content ${id} by admin`); const deleted = await this.contentsRepository.softDeleteAdmin(id); if (deleted) { await this.clearContentsCache(); } return deleted; } async updateAdmin(id: string, data: any) { this.logger.log(`Updating content ${id} by admin`); const updated = await this.contentsRepository.update(id, data); if (updated) { await this.clearContentsCache(); } return updated; } async update(id: string, userId: string, data: any) { this.logger.log(`Updating content ${id} for user ${userId}`); // Vérifier que le contenu appartient à l'utilisateur const existing = await this.contentsRepository.findOne(id, userId); if (!existing || existing.userId !== userId) { throw new BadRequestException( "Contenu non trouvé ou vous n'avez pas la permission de le modifier.", ); } const updated = await this.contentsRepository.update(id, data); if (updated) { await this.clearContentsCache(); } return updated; } async findOne(idOrSlug: string, userId?: string) { const content = await this.contentsRepository.findOne(idOrSlug, userId); if (!content) return null; return { ...content, url: this.s3Service.getPublicUrl(content.storageKey), author: { ...content.author, avatarUrl: content.author?.avatarUrl ? this.s3Service.getPublicUrl(content.author.avatarUrl) : null, }, }; } generateBotHtml(content: { title: string; storageKey: string }): string { const imageUrl = this.s3Service.getPublicUrl(content.storageKey); return ` ${content.title}

${content.title}

${content.title} `; } 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.contentsRepository.findBySlug(slug); if (!existing) break; slug = `${baseSlug}-${counter++}`; } return slug; } }