Harden media health checks and duration extraction
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
هذا الالتزام موجود في:
@@ -9,7 +9,7 @@ import {
|
||||
import { Upload } from '@aws-sdk/lib-storage';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { constants } from 'fs';
|
||||
import { access, mkdir, rm, unlink, writeFile } from 'fs/promises';
|
||||
import { access, mkdir, rm, stat, unlink, writeFile } from 'fs/promises';
|
||||
import { dirname, join, posix } from 'path';
|
||||
|
||||
@Injectable()
|
||||
@@ -186,16 +186,25 @@ export class ManagedStorageService implements OnModuleDestroy {
|
||||
async getHealth(): Promise<Record<string, unknown>> {
|
||||
const provider = this.getProvider();
|
||||
const basePath = this.getBasePath();
|
||||
const publicBaseUrl =
|
||||
(this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
const storagePublicBaseUrl = (
|
||||
this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? ''
|
||||
).replace(/\/$/, '');
|
||||
const publicBaseUrl = (
|
||||
this.configService.get<string>('publicBaseUrl', { infer: true }) ?? ''
|
||||
).replace(/\/$/, '');
|
||||
const health: Record<string, unknown> = {
|
||||
provider,
|
||||
storageProvider: provider,
|
||||
basePath,
|
||||
storageBasePath: basePath,
|
||||
publicPath: `/${basePath}`,
|
||||
uploadsPublicPath: `/${basePath}`,
|
||||
publicBaseUrlConfigured: !!publicBaseUrl,
|
||||
publicBaseUrl,
|
||||
storagePublicBaseUrlConfigured: !!storagePublicBaseUrl,
|
||||
storagePublicBaseUrl,
|
||||
isLocalStorage: provider === 'local',
|
||||
isS3Configured: provider === 's3' ? this.hasS3Configuration() : false,
|
||||
s3Configured: provider === 's3' ? this.hasS3Configuration() : undefined,
|
||||
};
|
||||
|
||||
@@ -219,12 +228,20 @@ export class ManagedStorageService implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
const exists = await this.pathExists(uploadDir);
|
||||
const readable = await this.canAccess(uploadDir, constants.R_OK);
|
||||
health.local = {
|
||||
runtimePath: uploadDir,
|
||||
absolutePath: uploadDir,
|
||||
exists,
|
||||
writable,
|
||||
readable: await this.canAccess(uploadDir, constants.R_OK),
|
||||
readable,
|
||||
error,
|
||||
};
|
||||
health.absolutePath = uploadDir;
|
||||
health.uploadPathExists = exists;
|
||||
health.uploadPathReadable = readable;
|
||||
health.uploadPathWritable = writable;
|
||||
}
|
||||
|
||||
return health;
|
||||
@@ -235,10 +252,12 @@ export class ManagedStorageService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
private getProvider(): 'local' | 's3' {
|
||||
return (this.configService.get<string>('storage.provider', { infer: true }) as
|
||||
| 'local'
|
||||
| 's3'
|
||||
| undefined) ?? 'local';
|
||||
return (
|
||||
(this.configService.get<string>('storage.provider', { infer: true }) as
|
||||
| 'local'
|
||||
| 's3'
|
||||
| undefined) ?? 'local'
|
||||
);
|
||||
}
|
||||
|
||||
private getBasePath(): string {
|
||||
@@ -251,9 +270,7 @@ export class ManagedStorageService implements OnModuleDestroy {
|
||||
const normalized = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
if (
|
||||
!normalized ||
|
||||
normalized
|
||||
.split('/')
|
||||
.some((segment) => !segment || segment === '.' || segment === '..')
|
||||
normalized.split('/').some((segment) => !segment || segment === '.' || segment === '..')
|
||||
) {
|
||||
throw new BadRequestException('Invalid managed file path');
|
||||
}
|
||||
@@ -301,19 +318,16 @@ export class ManagedStorageService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
private resolvePublicUrl(objectKey: string): string {
|
||||
const publicBaseUrl =
|
||||
(this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
const publicBaseUrl = (
|
||||
this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? ''
|
||||
).replace(/\/$/, '');
|
||||
if (publicBaseUrl) {
|
||||
return `${publicBaseUrl}/${objectKey}`;
|
||||
}
|
||||
|
||||
const endpoint = (this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
const endpoint = (
|
||||
this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? ''
|
||||
).replace(/\/$/, '');
|
||||
const bucket = this.getS3Bucket();
|
||||
const forcePathStyle =
|
||||
this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
|
||||
@@ -341,20 +355,17 @@ export class ManagedStorageService implements OnModuleDestroy {
|
||||
|
||||
private resolveS3ObjectKey(fileUrl: string): string | null {
|
||||
const normalizedUrl = fileUrl.split('?')[0].split('#')[0];
|
||||
const publicBaseUrl =
|
||||
(this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
const publicBaseUrl = (
|
||||
this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? ''
|
||||
).replace(/\/$/, '');
|
||||
|
||||
if (publicBaseUrl && normalizedUrl.startsWith(`${publicBaseUrl}/`)) {
|
||||
return normalizedUrl.slice(publicBaseUrl.length + 1);
|
||||
}
|
||||
|
||||
const endpoint = (this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
const endpoint = (
|
||||
this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? ''
|
||||
).replace(/\/$/, '');
|
||||
const bucket = this.getS3Bucket();
|
||||
const forcePathStyle =
|
||||
this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
|
||||
@@ -389,6 +400,15 @@ export class ManagedStorageService implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private async pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteS3Prefix(prefix: string): Promise<void> {
|
||||
const client = this.getS3Client();
|
||||
const bucket = this.getS3Bucket();
|
||||
|
||||
208
src/infrastructure/storage/media-probe.service.ts
Normal file
208
src/infrastructure/storage/media-probe.service.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { spawn } from 'child_process';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { mkdtemp, rm, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { extname, join } from 'path';
|
||||
|
||||
type CommandProbeResult = {
|
||||
path: string;
|
||||
available: boolean;
|
||||
version: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MediaProbeService {
|
||||
private readonly logger = new Logger(MediaProbeService.name);
|
||||
private readonly commandProbeCache = new Map<string, CommandProbeResult>();
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
getFfmpegPath(): string {
|
||||
return (
|
||||
this.configService.get<string>('videoProcessing.ffmpegPath', { infer: true }) ?? 'ffmpeg'
|
||||
).trim();
|
||||
}
|
||||
|
||||
getFfprobePath(): string {
|
||||
const configured = (
|
||||
this.configService.get<string>('videoProcessing.ffprobePath', { infer: true }) ?? ''
|
||||
).trim();
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
|
||||
const ffmpegPath = this.getFfmpegPath();
|
||||
if (/ffmpeg(?:\.exe)?$/i.test(ffmpegPath)) {
|
||||
return ffmpegPath.replace(/ffmpeg(?:\.exe)?$/i, (match) =>
|
||||
match.toLowerCase().endsWith('.exe') ? 'ffprobe.exe' : 'ffprobe',
|
||||
);
|
||||
}
|
||||
|
||||
return 'ffprobe';
|
||||
}
|
||||
|
||||
async checkFfmpeg(): Promise<CommandProbeResult> {
|
||||
return this.checkCommand(this.getFfmpegPath());
|
||||
}
|
||||
|
||||
async checkFfprobe(): Promise<CommandProbeResult> {
|
||||
return this.checkCommand(this.getFfprobePath());
|
||||
}
|
||||
|
||||
async extractDurationSecondsFromBuffer(
|
||||
buffer: Buffer,
|
||||
options: { originalname?: string; mimetype?: string } = {},
|
||||
): Promise<number | null> {
|
||||
if (!buffer.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workingDir = await mkdtemp(join(tmpdir(), 'oudelaa-media-probe-'));
|
||||
const inputPath = join(
|
||||
workingDir,
|
||||
`input-${randomUUID()}${this.resolveInputExtension(options)}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
return await this.extractDurationSeconds(inputPath);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Media duration extraction failed for "${options.originalname ?? 'upload'}": ${
|
||||
error instanceof Error ? error.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
await rm(workingDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async extractDurationSeconds(filePath: string): Promise<number | null> {
|
||||
const ffprobe = await this.checkFfprobe();
|
||||
if (!ffprobe.available) {
|
||||
this.logger.warn(
|
||||
`ffprobe is unavailable at "${ffprobe.path}"; media duration extraction skipped`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stdout = await this.runCommand(
|
||||
ffprobe.path,
|
||||
[
|
||||
'-v',
|
||||
'error',
|
||||
'-show_entries',
|
||||
'format=duration',
|
||||
'-of',
|
||||
'default=noprint_wrappers=1:nokey=1',
|
||||
filePath,
|
||||
],
|
||||
5000,
|
||||
);
|
||||
const seconds = Number.parseFloat(stdout.trim());
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.round(seconds);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`ffprobe duration check failed: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async checkCommand(command: string): Promise<CommandProbeResult> {
|
||||
const cached = this.commandProbeCache.get(command);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const stdout = await this.runCommand(command, ['-version'], 3000);
|
||||
const version = stdout.split(/\r?\n/)[0]?.trim() ?? '';
|
||||
const result = { path: command, available: true, version };
|
||||
this.commandProbeCache.set(command, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const result = {
|
||||
path: command,
|
||||
available: false,
|
||||
version: '',
|
||||
error: error instanceof Error ? error.message : 'unknown command error',
|
||||
};
|
||||
this.commandProbeCache.set(command, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveInputExtension(options: { originalname?: string; mimetype?: string }): string {
|
||||
const extension = extname(options.originalname ?? '').toLowerCase();
|
||||
if (extension) {
|
||||
return extension;
|
||||
}
|
||||
|
||||
switch (options.mimetype) {
|
||||
case 'video/mp4':
|
||||
return '.mp4';
|
||||
case 'video/quicktime':
|
||||
return '.mov';
|
||||
case 'video/webm':
|
||||
return '.webm';
|
||||
case 'audio/mpeg':
|
||||
return '.mp3';
|
||||
case 'audio/mp4':
|
||||
case 'audio/x-m4a':
|
||||
return '.m4a';
|
||||
case 'audio/wav':
|
||||
case 'audio/x-wav':
|
||||
return '.wav';
|
||||
case 'audio/aac':
|
||||
return '.aac';
|
||||
case 'audio/ogg':
|
||||
return '.ogg';
|
||||
default:
|
||||
return '.media';
|
||||
}
|
||||
}
|
||||
|
||||
private async runCommand(command: string, args: string[], timeoutMs: number): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const child = spawn(command, args, { windowsHide: true });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error(`${command} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (code === 0) {
|
||||
resolve(stdout);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(stderr.trim() || `${command} exited with code ${code ?? 'unknown'}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ImageProcessingService } from './image-processing.service';
|
||||
import { ManagedStorageService } from './managed-storage.service';
|
||||
import { MediaProbeService } from './media-probe.service';
|
||||
import { VideoProcessingService } from './video-processing.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [ManagedStorageService, VideoProcessingService, ImageProcessingService],
|
||||
exports: [ManagedStorageService, VideoProcessingService, ImageProcessingService],
|
||||
providers: [
|
||||
ManagedStorageService,
|
||||
VideoProcessingService,
|
||||
ImageProcessingService,
|
||||
MediaProbeService,
|
||||
],
|
||||
exports: [
|
||||
ManagedStorageService,
|
||||
VideoProcessingService,
|
||||
ImageProcessingService,
|
||||
MediaProbeService,
|
||||
],
|
||||
})
|
||||
export class StorageModule {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { spawn } from 'child_process';
|
||||
import { randomUUID } from 'crypto';
|
||||
@@ -165,9 +165,7 @@ export class VideoProcessingService {
|
||||
error instanceof Error ? error.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
'Video optimization failed. Upload an MP4/WebM video or disable server-side video processing.',
|
||||
);
|
||||
return { file };
|
||||
} finally {
|
||||
await rm(workingDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -453,7 +451,11 @@ export class VideoProcessingService {
|
||||
}
|
||||
|
||||
private buildHlsRenditions(sourceWidth: number): HlsRendition[] {
|
||||
const requestedWidths = [Math.min(480, this.getMaxWidth()), Math.min(720, this.getMaxWidth()), this.getMaxWidth()];
|
||||
const requestedWidths = [
|
||||
Math.min(480, this.getMaxWidth()),
|
||||
Math.min(720, this.getMaxWidth()),
|
||||
this.getMaxWidth(),
|
||||
];
|
||||
const widths = Array.from(
|
||||
new Set(
|
||||
requestedWidths
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم