feat: add MediaModule with service for virus scanning and media processing

Introduced MediaModule with MediaService to handle antivirus scanning using ClamAV and media file processing for images (webp/avif) and videos (webm/av1). Includes media-related interfaces and module exports for broader application integration.
This commit is contained in:
Mathis HERRIOT
2026-01-08 15:26:25 +01:00
parent 92ea36545a
commit dd875fe1ea
3 changed files with 185 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
export interface MediaProcessingResult {
buffer: Buffer;
mimeType: string;
extension: string;
width?: number;
height?: number;
size: number;
}
export interface ScanResult {
isInfected: boolean;
virusName?: string;
}

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { MediaService } from "./media.service";
@Module({
providers: [MediaService],
exports: [MediaService],
})
export class MediaModule {}

View File

@@ -0,0 +1,164 @@
import {
BadRequestException,
Injectable,
InternalServerErrorException,
Logger,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import * as NodeClam from "clamscan";
import ffmpeg from "fluent-ffmpeg";
import { readFile, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
import sharp from "sharp";
import { v4 as uuidv4 } from "uuid";
import type {
MediaProcessingResult,
ScanResult,
} from "./interfaces/media.interface";
interface ClamScanner {
scanStream(stream: Readable): Promise<{ isInfected: boolean; viruses: string[] }>;
}
@Injectable()
export class MediaService {
private readonly logger = new Logger(MediaService.name);
private clamscan: ClamScanner | null = null;
private isClamAvInitialized = false;
constructor(private readonly configService: ConfigService) {
this.initClamScan();
}
private async initClamScan() {
try {
// @ts-ignore
const scanner = await new NodeClam().init({
clamdscan: {
host: this.configService.get<string>("CLAMAV_HOST", "localhost"),
port: this.configService.get<number>("CLAMAV_PORT", 3310),
timeout: 60000,
},
preference: "clamdscan",
});
this.clamscan = scanner;
this.isClamAvInitialized = true;
this.logger.log("ClamAV scanner initialized successfully");
} catch (error) {
this.logger.warn(
`ClamAV scanner could not be initialized: ${error.message}. Antivirus scanning will be skipped.`,
);
}
}
async scanFile(buffer: Buffer, filename: string): Promise<ScanResult> {
if (!this.isClamAvInitialized || !this.clamscan) {
this.logger.warn("ClamAV not initialized, skipping scan");
return { isInfected: false };
}
try {
const stream = Readable.from(buffer);
const { isInfected, viruses } = await this.clamscan.scanStream(stream);
if (isInfected) {
this.logger.error(
`Virus detected in file ${filename}: ${viruses.join(", ")}`,
);
}
return {
isInfected,
virusName: isInfected ? viruses[0] : undefined,
};
} catch (error) {
this.logger.error(`Error scanning file ${filename}: ${error.message}`);
throw new InternalServerErrorException("Error during virus scan");
}
}
async processImage(
buffer: Buffer,
format: "webp" | "avif" = "webp",
): Promise<MediaProcessingResult> {
try {
let pipeline = sharp(buffer);
const metadata = await pipeline.metadata();
if (format === "webp") {
pipeline = pipeline.webp({ quality: 80, effort: 6 });
} else {
pipeline = pipeline.avif({ quality: 65, effort: 6 });
}
const processedBuffer = await pipeline.toBuffer();
return {
buffer: processedBuffer,
mimeType: `image/${format}`,
extension: format,
width: metadata.width,
height: metadata.height,
size: processedBuffer.length,
};
} catch (error) {
this.logger.error(`Error processing image: ${error.message}`);
throw new BadRequestException("Failed to process image");
}
}
async processVideo(
buffer: Buffer,
format: "webm" | "av1" = "webm",
): Promise<MediaProcessingResult> {
const tempInput = join(tmpdir(), `${uuidv4()}.tmp`);
const tempOutput = join(
tmpdir(),
`${uuidv4()}.${format === "av1" ? "mp4" : "webm"}`,
);
try {
await writeFile(tempInput, buffer);
await new Promise<void>((resolve, reject) => {
let command = ffmpeg(tempInput);
if (format === "webm") {
command = command
.toFormat("webm")
.videoCodec("libvpx-vp9")
.audioCodec("libopus")
.outputOptions("-crf 30", "-b:v 0");
} else {
command = command
.toFormat("mp4")
.videoCodec("libaom-av1")
.audioCodec("libopus")
.outputOptions("-crf 34", "-b:v 0", "-strict experimental");
}
command
.on("end", () => resolve())
.on("error", (err) => reject(err))
.save(tempOutput);
});
const processedBuffer = await readFile(tempOutput);
return {
buffer: processedBuffer,
mimeType: format === "av1" ? "video/mp4" : "video/webm",
extension: format === "av1" ? "mp4" : "webm",
size: processedBuffer.length,
};
} catch (error) {
this.logger.error(`Error processing video: ${error.message}`);
throw new BadRequestException("Failed to process video");
} finally {
await unlink(tempInput).catch(() => {});
await unlink(tempOutput).catch(() => {});
}
}
}