diff --git a/backend/src/media/interfaces/media.interface.ts b/backend/src/media/interfaces/media.interface.ts new file mode 100644 index 0000000..b9ae15b --- /dev/null +++ b/backend/src/media/interfaces/media.interface.ts @@ -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; +} diff --git a/backend/src/media/media.module.ts b/backend/src/media/media.module.ts new file mode 100644 index 0000000..7972839 --- /dev/null +++ b/backend/src/media/media.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; +import { MediaService } from "./media.service"; + +@Module({ + providers: [MediaService], + exports: [MediaService], +}) +export class MediaModule {} diff --git a/backend/src/media/media.service.ts b/backend/src/media/media.service.ts new file mode 100644 index 0000000..220a195 --- /dev/null +++ b/backend/src/media/media.service.ts @@ -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("CLAMAV_HOST", "localhost"), + port: this.configService.get("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 { + 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 { + 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 { + const tempInput = join(tmpdir(), `${uuidv4()}.tmp`); + const tempOutput = join( + tmpdir(), + `${uuidv4()}.${format === "av1" ? "mp4" : "webm"}`, + ); + + try { + await writeFile(tempInput, buffer); + + await new Promise((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(() => {}); + } + } +}