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:
13
backend/src/media/interfaces/media.interface.ts
Normal file
13
backend/src/media/interfaces/media.interface.ts
Normal 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;
|
||||||
|
}
|
||||||
8
backend/src/media/media.module.ts
Normal file
8
backend/src/media/media.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { MediaService } from "./media.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [MediaService],
|
||||||
|
exports: [MediaService],
|
||||||
|
})
|
||||||
|
export class MediaModule {}
|
||||||
164
backend/src/media/media.service.ts
Normal file
164
backend/src/media/media.service.ts
Normal 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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user