Files
memegoat/backend/src/contents/contents.service.ts
Mathis HERRIOT 8d27532dc0
Some checks failed
Backend Tests / test (push) Successful in 1m11s
Lint / lint (backend) (push) Failing after 46s
Lint / lint (documentation) (push) Successful in 1m7s
Lint / lint (frontend) (push) Has been cancelled
feat(s3): enhance logging and public URL generation
Add detailed logging for S3 uploads in user and content services. Improve public URL generation logic in `S3Service` by providing better handling for `API_URL`, `DOMAIN_NAME`, and `PORT`. Update relevant tests to cover all scenarios.
2026-01-15 00:40:36 +01:00

249 lines
7.0 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",
];
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);
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 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;
}
}