- Introduced "video" as a new content type across backend and frontend. - Updated validation schemas and MIME-type handling for video files. - Implemented file size limits for videos (10 MB max) in configuration. - Enhanced upload flow with auto-detection of file types (image, GIF, video). - Expanded media processing to handle video files and convert them to WebM format.
290 lines
8.1 KiB
TypeScript
290 lines
8.1 KiB
TypeScript
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 { 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 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}`);
|
|
// 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)) {
|
|
throw new BadRequestException(
|
|
"Format de fichier non supporté. Formats acceptés: png, jpeg, jpg, webp, webm, mp4, mov, gif.",
|
|
);
|
|
}
|
|
|
|
const isGif = file.mimetype === "image/gif";
|
|
const isVideo = file.mimetype.startsWith("video/");
|
|
let maxSizeKb: number;
|
|
|
|
if (isGif) {
|
|
maxSizeKb = this.configService.get<number>("MAX_GIF_SIZE_KB", 1024);
|
|
} else if (isVideo) {
|
|
maxSizeKb = this.configService.get<number>("MAX_VIDEO_SIZE_KB", 10240);
|
|
} else {
|
|
maxSizeKb = this.configService.get<number>("MAX_IMAGE_SIZE_KB", 512);
|
|
}
|
|
|
|
if (file.size > maxSizeKb * 1024) {
|
|
throw new BadRequestException(
|
|
`Fichier trop volumineux. Limite pour ${isGif ? "GIF" : isVideo ? "vidéo" : "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/") && 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
|
|
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
|
|
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 [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 `<!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>`;
|
|
}
|
|
|
|
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.contentsRepository.findBySlug(slug);
|
|
|
|
if (!existing) break;
|
|
slug = `${baseSlug}-${counter++}`;
|
|
}
|
|
return slug;
|
|
}
|
|
}
|