From 637782aed648fe1b0964eb319110b20c9d09230a Mon Sep 17 00:00:00 2001 From: boutmoun123 Date: Sun, 31 May 2026 18:53:07 +0300 Subject: [PATCH] Harden media health checks and duration extraction --- .env.example | 4 + .../app/(dashboard)/dashboard/page.tsx | 64 ++++- oudelaa_dashboard/lib/format.ts | 10 +- src/config/configuration.ts | 13 +- src/config/validation.schema.ts | 2 + .../storage/managed-storage.service.ts | 84 ++++--- .../storage/media-probe.service.ts | 208 ++++++++++++++++ src/infrastructure/storage/storage.module.ts | 15 +- .../storage/video-processing.service.ts | 12 +- src/modules/media/media.service.ts | 88 +++++-- src/modules/posts/posts.service.ts | 225 +++++++++++++----- 11 files changed, 587 insertions(+), 138 deletions(-) create mode 100644 src/infrastructure/storage/media-probe.service.ts diff --git a/.env.example b/.env.example index 089acea..e9150d2 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,8 @@ QUEUE_WORKER_CONCURRENCY=5 STORAGE_PROVIDER=local STORAGE_BASE_PATH=uploads +# In Docker/production with local storage, mount a persistent volume to /app/uploads +# or to the runtime path resolved from STORAGE_BASE_PATH. # Leave empty for local storage unless you want a dedicated CDN/base URL. STORAGE_PUBLIC_BASE_URL= S3_BUCKET= @@ -76,6 +78,8 @@ VIDEO_PROCESSING_GENERATE_THUMBNAILS=true VIDEO_PROCESSING_GENERATE_HLS=true VIDEO_PROCESSING_HLS_SEGMENT_DURATION_SECONDS=4 VIDEO_PROCESSING_THUMBNAIL_WIDTH=720 +AUDIO_PROCESSING_ENABLED=false +AUDIO_WAVEFORM_PEAKS=48 GOOGLE_CLIENT_ID=your_google_client_id GOOGLE_CLIENT_SECRET=your_google_client_secret diff --git a/oudelaa_dashboard/app/(dashboard)/dashboard/page.tsx b/oudelaa_dashboard/app/(dashboard)/dashboard/page.tsx index 17aa27b..11b556e 100644 --- a/oudelaa_dashboard/app/(dashboard)/dashboard/page.tsx +++ b/oudelaa_dashboard/app/(dashboard)/dashboard/page.tsx @@ -2,7 +2,16 @@ import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; -import { Boxes, RefreshCcw, ShieldAlert, Store, Users2 } from "lucide-react"; +import { + Boxes, + FileText, + MessageSquareText, + RefreshCcw, + ShieldAlert, + Store, + UserRound, + Users2, +} from "lucide-react"; import { NoPermissionState } from "@/components/auth/no-permission-state"; import { PostPreviewCard } from "@/components/dashboard/post-preview-card"; @@ -69,6 +78,34 @@ function ShortcutLink({ ); } +function getActivityIcon(type: string) { + const normalized = type.toLowerCase(); + if (normalized.includes("user")) return UserRound; + if (normalized.includes("comment")) return MessageSquareText; + if (normalized.includes("listing") || normalized.includes("shop")) return Store; + if (normalized.includes("case") || normalized.includes("audit") || normalized.includes("report")) { + return ShieldAlert; + } + return FileText; +} + +function ActivityTypeIcon({ type }: { type: string }) { + const Icon = getActivityIcon(type); + return ; +} + +function getActivityLabel(type: string) { + const normalized = type.toLowerCase(); + if (normalized === "post") return "New post"; + if (normalized === "user") return "New user"; + if (normalized === "comment") return "New comment"; + if (normalized === "listing") return "Marketplace listing"; + if (normalized === "repair_shop") return "Repair shop"; + if (normalized === "case") return "Moderation case"; + if (normalized === "audit") return "Audit event"; + return type.replace(/_/g, " "); +} + export default function DashboardPage() { const { permissions } = useSuperAdminSession(); const [snapshot, setSnapshot] = useState({ @@ -417,26 +454,33 @@ export default function DashboardPage() { } > - Recent activity + Platform activity - Activity feeds + View analytics {!snapshot.recentActivity.length ? ( ) : (
- {snapshot.recentActivity.map((item, index) => ( + {snapshot.recentActivity.slice(0, 5).map((item, index) => (
-
{item.title}
+
+ + + +
+ {item.title || "Untitled activity"} +
+
- {item.type} + {getActivityLabel(item.type)}
-
- {item.subtitle} • {formatDateTime(item.createdAt)} +
+ {item.subtitle || "No additional details"} - {formatDateTime(item.createdAt)}
))} diff --git a/oudelaa_dashboard/lib/format.ts b/oudelaa_dashboard/lib/format.ts index 2d2b831..6d2f6bc 100644 --- a/oudelaa_dashboard/lib/format.ts +++ b/oudelaa_dashboard/lib/format.ts @@ -1,9 +1,13 @@ export function formatDateTime(value?: string | null) { if (!value) return "-"; try { - return new Intl.DateTimeFormat("ar-SA", { - dateStyle: "medium", - timeStyle: "short", + return new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: true, }).format(new Date(value)); } catch { return value; diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 09f64db..f753b9e 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -3,8 +3,7 @@ export default () => ({ port: Number(process.env.PORT ?? 4000), host: process.env.HOST ?? '0.0.0.0', publicBaseUrl: - process.env.PUBLIC_BASE_URL ?? - `http://localhost:${Number(process.env.PORT ?? 4000)}`, + process.env.PUBLIC_BASE_URL ?? `http://localhost:${Number(process.env.PORT ?? 4000)}`, responseEnvelopeEnabled: (process.env.RESPONSE_ENVELOPE_ENABLED ?? 'false').toLowerCase() === 'true', globalPrefix: process.env.GLOBAL_PREFIX ?? 'api/v1', @@ -76,8 +75,7 @@ export default () => ({ name: process.env.QUEUE_NAME ?? 'app-jobs', defaultJobAttempts: Number(process.env.QUEUE_DEFAULT_ATTEMPTS ?? 3), defaultJobBackoffMs: Number(process.env.QUEUE_DEFAULT_BACKOFF_MS ?? 1000), - removeOnComplete: - (process.env.QUEUE_REMOVE_ON_COMPLETE ?? 'true').toLowerCase() === 'true', + removeOnComplete: (process.env.QUEUE_REMOVE_ON_COMPLETE ?? 'true').toLowerCase() === 'true', workerConcurrency: Number(process.env.QUEUE_WORKER_CONCURRENCY ?? 5), }, storage: { @@ -90,8 +88,7 @@ export default () => ({ endpoint: process.env.S3_ENDPOINT ?? '', accessKeyId: process.env.S3_ACCESS_KEY_ID ?? '', secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? '', - forcePathStyle: - (process.env.S3_FORCE_PATH_STYLE ?? 'false').toLowerCase() === 'true', + forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? 'false').toLowerCase() === 'true', }, }, imageProcessing: { @@ -125,6 +122,10 @@ export default () => ({ ), thumbnailWidth: Number(process.env.VIDEO_PROCESSING_THUMBNAIL_WIDTH ?? 720), }, + audioProcessing: { + enabled: (process.env.AUDIO_PROCESSING_ENABLED ?? 'false').toLowerCase() === 'true', + waveformPeaks: Number(process.env.AUDIO_WAVEFORM_PEAKS ?? 48), + }, logging: { level: process.env.LOG_LEVEL ?? 'log', requestEnabled: (process.env.REQUEST_LOGGING_ENABLED ?? 'true').toLowerCase() === 'true', diff --git a/src/config/validation.schema.ts b/src/config/validation.schema.ts index 058ce12..f31fd56 100644 --- a/src/config/validation.schema.ts +++ b/src/config/validation.schema.ts @@ -81,6 +81,8 @@ export const validationSchema = Joi.object({ VIDEO_PROCESSING_GENERATE_HLS: Joi.boolean().truthy('true').falsy('false').default(true), VIDEO_PROCESSING_HLS_SEGMENT_DURATION_SECONDS: Joi.number().min(2).max(20).default(4), VIDEO_PROCESSING_THUMBNAIL_WIDTH: Joi.number().min(160).max(1920).default(720), + AUDIO_PROCESSING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false), + AUDIO_WAVEFORM_PEAKS: Joi.number().min(16).max(256).default(48), LOG_LEVEL: Joi.string().valid('error', 'warn', 'log', 'debug', 'verbose').default('log'), REQUEST_LOGGING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true), FEED_CACHE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true), diff --git a/src/infrastructure/storage/managed-storage.service.ts b/src/infrastructure/storage/managed-storage.service.ts index d661066..ecec386 100644 --- a/src/infrastructure/storage/managed-storage.service.ts +++ b/src/infrastructure/storage/managed-storage.service.ts @@ -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> { const provider = this.getProvider(); const basePath = this.getBasePath(); - const publicBaseUrl = - (this.configService.get('storage.publicBaseUrl', { infer: true }) ?? '').replace( - /\/$/, - '', - ); + const storagePublicBaseUrl = ( + this.configService.get('storage.publicBaseUrl', { infer: true }) ?? '' + ).replace(/\/$/, ''); + const publicBaseUrl = ( + this.configService.get('publicBaseUrl', { infer: true }) ?? '' + ).replace(/\/$/, ''); const health: Record = { 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('storage.provider', { infer: true }) as - | 'local' - | 's3' - | undefined) ?? 'local'; + return ( + (this.configService.get('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('storage.publicBaseUrl', { infer: true }) ?? '').replace( - /\/$/, - '', - ); + const publicBaseUrl = ( + this.configService.get('storage.publicBaseUrl', { infer: true }) ?? '' + ).replace(/\/$/, ''); if (publicBaseUrl) { return `${publicBaseUrl}/${objectKey}`; } - const endpoint = (this.configService.get('storage.s3.endpoint', { infer: true }) ?? '').replace( - /\/$/, - '', - ); + const endpoint = ( + this.configService.get('storage.s3.endpoint', { infer: true }) ?? '' + ).replace(/\/$/, ''); const bucket = this.getS3Bucket(); const forcePathStyle = this.configService.get('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('storage.publicBaseUrl', { infer: true }) ?? '').replace( - /\/$/, - '', - ); + const publicBaseUrl = ( + this.configService.get('storage.publicBaseUrl', { infer: true }) ?? '' + ).replace(/\/$/, ''); if (publicBaseUrl && normalizedUrl.startsWith(`${publicBaseUrl}/`)) { return normalizedUrl.slice(publicBaseUrl.length + 1); } - const endpoint = (this.configService.get('storage.s3.endpoint', { infer: true }) ?? '').replace( - /\/$/, - '', - ); + const endpoint = ( + this.configService.get('storage.s3.endpoint', { infer: true }) ?? '' + ).replace(/\/$/, ''); const bucket = this.getS3Bucket(); const forcePathStyle = this.configService.get('storage.s3.forcePathStyle', { infer: true }) ?? false; @@ -389,6 +400,15 @@ export class ManagedStorageService implements OnModuleDestroy { } } + private async pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } + } + private async deleteS3Prefix(prefix: string): Promise { const client = this.getS3Client(); const bucket = this.getS3Bucket(); diff --git a/src/infrastructure/storage/media-probe.service.ts b/src/infrastructure/storage/media-probe.service.ts new file mode 100644 index 0000000..1b85429 --- /dev/null +++ b/src/infrastructure/storage/media-probe.service.ts @@ -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(); + + constructor(private readonly configService: ConfigService) {} + + getFfmpegPath(): string { + return ( + this.configService.get('videoProcessing.ffmpegPath', { infer: true }) ?? 'ffmpeg' + ).trim(); + } + + getFfprobePath(): string { + const configured = ( + this.configService.get('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 { + return this.checkCommand(this.getFfmpegPath()); + } + + async checkFfprobe(): Promise { + return this.checkCommand(this.getFfprobePath()); + } + + async extractDurationSecondsFromBuffer( + buffer: Buffer, + options: { originalname?: string; mimetype?: string } = {}, + ): Promise { + 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 { + 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 { + 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 { + return new Promise((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'}`)); + }); + }); + } +} diff --git a/src/infrastructure/storage/storage.module.ts b/src/infrastructure/storage/storage.module.ts index ff5c516..751c70d 100644 --- a/src/infrastructure/storage/storage.module.ts +++ b/src/infrastructure/storage/storage.module.ts @@ -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 {} diff --git a/src/infrastructure/storage/video-processing.service.ts b/src/infrastructure/storage/video-processing.service.ts index 6cb9977..1a84e0a 100644 --- a/src/infrastructure/storage/video-processing.service.ts +++ b/src/infrastructure/storage/video-processing.service.ts @@ -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 diff --git a/src/modules/media/media.service.ts b/src/modules/media/media.service.ts index 30b9259..7aa803c 100644 --- a/src/modules/media/media.service.ts +++ b/src/modules/media/media.service.ts @@ -1,12 +1,9 @@ -import { - BadGatewayException, - Injectable, - ServiceUnavailableException, -} from '@nestjs/common'; +import { BadGatewayException, Injectable, ServiceUnavailableException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { GoogleAuth } from 'google-auth-library'; import { generateWaveformPeaksFromBuffer } from '../../common/utils/waveform.util'; import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service'; +import { MediaProbeService } from '../../infrastructure/storage/media-probe.service'; import { TextToMusicDto } from './dto/text-to-music.dto'; @Injectable() @@ -14,29 +11,84 @@ export class MediaService { constructor( private readonly configService: ConfigService, private readonly storageService: ManagedStorageService, + private readonly mediaProbeService: MediaProbeService, ) {} async getMediaHealth() { + const [storageHealth, ffmpeg, ffprobe] = await Promise.all([ + this.storageService.getHealth(), + this.mediaProbeService.checkFfmpeg(), + this.mediaProbeService.checkFfprobe(), + ]); + const storageProvider = + this.configService.get('storage.provider', { infer: true }) ?? 'local'; + const publicBaseUrl = this.configService.get('publicBaseUrl', { infer: true }) ?? ''; + const warnings: string[] = []; + const imageProcessingEnabled = + this.configService.get('imageProcessing.enabled', { infer: true }) ?? false; + const videoProcessingEnabled = + this.configService.get('videoProcessing.enabled', { infer: true }) ?? false; + const videoHlsGenerationEnabled = + this.configService.get('videoProcessing.generateHls', { infer: true }) ?? true; + const videoThumbnailGenerationEnabled = + this.configService.get('videoProcessing.generateThumbnails', { infer: true }) ?? + true; + const audioProcessingEnabled = + this.configService.get('audioProcessing.enabled', { infer: true }) ?? false; + + if (storageProvider === 'local') { + warnings.push( + 'Local storage requires persistent volume mounted to /app/uploads in production', + ); + } + if (!publicBaseUrl) { + warnings.push('PUBLIC_BASE_URL is not configured'); + } + if ((imageProcessingEnabled || videoProcessingEnabled) && !ffmpeg.available) { + warnings.push('ffmpeg is not available; image/video processing may fail'); + } + if (!ffprobe.available) { + warnings.push('ffprobe is not available; duration extraction may fail'); + } + if (storageProvider === 's3' && !(storageHealth.isS3Configured ?? storageHealth.s3Configured)) { + warnings.push('S3 provider selected but missing required env variables'); + } + + const storageWritable = + storageProvider !== 'local' || storageHealth.uploadPathWritable !== false; + const status = !storageWritable ? 'error' : warnings.length ? 'warning' : 'ok'; + return { - storage: await this.storageService.getHealth(), + status, + storage: storageHealth, processing: { - imageProcessingEnabled: - this.configService.get('imageProcessing.enabled', { infer: true }) ?? false, - videoProcessingEnabled: - this.configService.get('videoProcessing.enabled', { infer: true }) ?? false, - videoHlsGenerationEnabled: - this.configService.get('videoProcessing.generateHls', { infer: true }) ?? true, - videoThumbnailGenerationEnabled: - this.configService.get('videoProcessing.generateThumbnails', { infer: true }) ?? - true, - ffmpegPath: - this.configService.get('videoProcessing.ffmpegPath', { infer: true }) ?? 'ffmpeg', + imageProcessingEnabled, + videoProcessingEnabled, + videoHlsGenerationEnabled, + videoThumbnailGenerationEnabled, + audioProcessingEnabled, + ffmpegPath: ffmpeg.path, + ffmpegAvailable: ffmpeg.available, + ffmpegVersion: ffmpeg.version, + ffprobePath: ffprobe.path, + ffprobeAvailable: ffprobe.available, + ffprobeVersion: ffprobe.version, + ffmpeg, + ffprobe, }, serving: { rangeRequests: true, immutableCacheSeconds: 31536000, hlsManifestCacheSeconds: 300, }, + staticServing: { + uploadsPublicPath: + storageHealth.uploadsPublicPath ?? storageHealth.publicPath ?? '/uploads', + rangeRequestsExpected: true, + cacheHeadersExpected: true, + hlsMimeExpected: 'application/vnd.apple.mpegurl', + }, + warnings, }; } @@ -67,7 +119,7 @@ export class MediaService { const client = await auth.getClient(); const accessTokenRaw = await client.getAccessToken(); const accessToken = - typeof accessTokenRaw === 'string' ? accessTokenRaw : accessTokenRaw?.token ?? ''; + typeof accessTokenRaw === 'string' ? accessTokenRaw : (accessTokenRaw?.token ?? ''); if (!accessToken) { throw new ServiceUnavailableException('Failed to authenticate with Google Cloud'); diff --git a/src/modules/posts/posts.service.ts b/src/modules/posts/posts.service.ts index 2c75f94..e7c7a7d 100644 --- a/src/modules/posts/posts.service.ts +++ b/src/modules/posts/posts.service.ts @@ -1,4 +1,10 @@ -import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; import { extname } from 'path'; import { Connection, Types } from 'mongoose'; import { InjectConnection } from '@nestjs/mongoose'; @@ -23,6 +29,7 @@ import { UploadedVideoFile, VideoProcessingService, } from '../../infrastructure/storage/video-processing.service'; +import { MediaProbeService } from '../../infrastructure/storage/media-probe.service'; import { NotificationsService } from '../notifications/notifications.service'; import { AuditService } from '../audit/audit.service'; import { UsersRepository } from '../users/users.repository'; @@ -39,12 +46,7 @@ import { PostsRepository } from './posts.repository'; type PostMediaMetadataInput = Pick< CreatePostDto, - | 'durationSeconds' - | 'thumbnailUrl' - | 'style' - | 'maqam' - | 'rhythmSignature' - | 'waveformPeaks' + 'durationSeconds' | 'thumbnailUrl' | 'style' | 'maqam' | 'rhythmSignature' | 'waveformPeaks' >; type NormalizedPostMediaMetadata = { @@ -61,6 +63,7 @@ type SavedVideoUpload = { hlsUrl: string; thumbnailUrl: string; thumbnailVariants: PostMediaVariantSet | null; + durationSeconds: number | null; }; type SavedImageUpload = { @@ -79,6 +82,7 @@ export class PostsService { private readonly storageService: ManagedStorageService, private readonly imageProcessingService: ImageProcessingService, private readonly videoProcessingService: VideoProcessingService, + private readonly mediaProbeService: MediaProbeService, private readonly feedVersionService: FeedVersionService, private readonly notificationsService: NotificationsService, private readonly auditService: AuditService, @@ -87,7 +91,12 @@ export class PostsService { async create( userId: string, dto: CreatePostDto, - imageFiles: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }> = [], + imageFiles: Array<{ + mimetype?: string; + size: number; + buffer: Buffer; + originalname?: string; + }> = [], videoFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, audioFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, coverImageFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, @@ -134,6 +143,9 @@ export class PostsService { const generatedThumbnailVariants = savedVideoUpload?.thumbnailVariants ?? null; const uploadedThumbnailUrl = savedCoverImageUpload?.primaryUrl ?? generatedThumbnailUrl; const uploadedThumbnailVariants = savedCoverImageUpload?.variants ?? generatedThumbnailVariants; + const uploadedAudioDurationSeconds = audioFile + ? await this.mediaProbeService.extractDurationSecondsFromBuffer(audioFile.buffer, audioFile) + : null; const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : ''; const finalImageUrls = uploadedImageUrls.length ? uploadedImageUrls : inputImageUrls; const finalImageVariants = uploadedImageVariants.length ? uploadedImageVariants : []; @@ -141,9 +153,18 @@ export class PostsService { const finalAudioUrl = uploadedAudioUrl || dto.audioUrl || ''; const finalContent = dto.content?.trim() ?? ''; const taggedUserIds = await this.normalizeTaggedUserIds(dto.taggedUserIds, userId); - const collaboratorIds = await this.normalizeUserIdList(dto.collaboratorIds, userId, 5, 'collaborator'); + const collaboratorIds = await this.normalizeUserIdList( + dto.collaboratorIds, + userId, + 5, + 'collaborator', + ); const imageItems = this.buildImageItems(finalImageUrls, dto.imageCaptions, dto.imageAltTexts); - const mentionResolution = await this.resolveMentionTargets(dto.mentionUsernames, finalContent, userId); + const mentionResolution = await this.resolveMentionTargets( + dto.mentionUsernames, + finalContent, + userId, + ); const { location, latitude, longitude } = this.normalizeLocation(dto); if (!finalContent && !finalImageUrls.length && !finalVideoUrl && !finalAudioUrl) { throw new BadRequestException('Post must contain caption or media'); @@ -153,6 +174,7 @@ export class PostsService { const hashtags = this.extractHashtags(finalContent); const mediaMetadata = this.normalizeMediaMetadata(dto, postType, undefined, { audioSourceBuffer: audioFile?.buffer, + extractedDurationSeconds: savedVideoUpload?.durationSeconds ?? uploadedAudioDurationSeconds, waveformSeed: finalAudioUrl || finalContent || `${userId}:${Date.now()}`, thumbnailUrl: uploadedThumbnailUrl, }); @@ -186,7 +208,9 @@ export class PostsService { await Promise.all([ ...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)), uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(), - uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(), + uploadedHlsUrl + ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) + : Promise.resolve(), uploadedThumbnailUrl ? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants) : Promise.resolve(), @@ -204,7 +228,12 @@ export class PostsService { await this.usersRepository.incrementPostsCount(userId, 1); await this.feedVersionService.bumpGlobalVersion(); - await this.notifyMentionedUsers(userId, post.id, mentionResolution.mentionedUsers, finalContent); + await this.notifyMentionedUsers( + userId, + post.id, + mentionResolution.mentionedUsers, + finalContent, + ); return post; } @@ -212,7 +241,12 @@ export class PostsService { userId: string, postId: string, dto: UpdatePostDto, - imageFiles: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }> = [], + imageFiles: Array<{ + mimetype?: string; + size: number; + buffer: Buffer; + originalname?: string; + }> = [], videoFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, audioFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, coverImageFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, @@ -249,7 +283,10 @@ export class PostsService { if (coverImageFile && (imageFiles.length || inputImageUrls.length)) { throw new BadRequestException('coverImageFile is allowed only with video or audio posts'); } - if (coverImageFile && !(videoFile || audioFile || dto.videoUrl || dto.audioUrl || post.videoUrl || post.audioUrl)) { + if ( + coverImageFile && + !(videoFile || audioFile || dto.videoUrl || dto.audioUrl || post.videoUrl || post.audioUrl) + ) { throw new BadRequestException('coverImageFile is allowed only with video or audio posts'); } if ((videoFile || dto.videoUrl) && (imageFiles.length || inputImageUrls.length)) { @@ -272,6 +309,9 @@ export class PostsService { const generatedThumbnailVariants = savedVideoUpload?.thumbnailVariants ?? null; const uploadedThumbnailUrl = savedCoverImageUpload?.primaryUrl ?? generatedThumbnailUrl; const uploadedThumbnailVariants = savedCoverImageUpload?.variants ?? generatedThumbnailVariants; + const uploadedAudioDurationSeconds = audioFile + ? await this.mediaProbeService.extractDurationSecondsFromBuffer(audioFile.buffer, audioFile) + : null; const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : ''; const hasImageUpdate = typeof dto.imageUrls !== 'undefined' || imageFiles.length > 0; const existingImageVariants = Array.isArray((post as any).imageVariants) @@ -286,7 +326,7 @@ export class PostsService { ? imageFiles.length ? uploadedImageUrls : inputImageUrls - : post.imageUrls ?? []; + : (post.imageUrls ?? []); const nextImageVariants = hasImageUpdate ? imageFiles.length ? uploadedImageVariants @@ -296,34 +336,39 @@ export class PostsService { const nextVideoUrl = hasVideoUpdate ? videoFile ? uploadedVideoUrl - : dto.videoUrl ?? '' - : post.videoUrl ?? ''; - const nextHlsUrl = hasVideoUpdate ? (videoFile ? uploadedHlsUrl : '') : post.hlsUrl ?? ''; + : (dto.videoUrl ?? '') + : (post.videoUrl ?? ''); + const nextHlsUrl = hasVideoUpdate ? (videoFile ? uploadedHlsUrl : '') : (post.hlsUrl ?? ''); const nextAudioUrl = hasAudioUpdate ? audioFile ? uploadedAudioUrl - : dto.audioUrl ?? '' - : post.audioUrl ?? ''; - const nextThumbnailVariants = coverImageFile || videoFile - ? uploadedThumbnailVariants - : typeof dto.thumbnailUrl === 'string' - ? null - : existingThumbnailVariants; + : (dto.audioUrl ?? '') + : (post.audioUrl ?? ''); + const nextThumbnailVariants = + coverImageFile || videoFile + ? uploadedThumbnailVariants + : typeof dto.thumbnailUrl === 'string' + ? null + : existingThumbnailVariants; const nextPostType = this.resolvePostType(nextImageUrls, nextVideoUrl, nextAudioUrl); - const nextContent = typeof dto.content === 'string' ? dto.content.trim() : post.content ?? ''; + const nextContent = typeof dto.content === 'string' ? dto.content.trim() : (post.content ?? ''); const nextTaggedUserIds = typeof dto.taggedUserIds !== 'undefined' ? await this.normalizeTaggedUserIds(dto.taggedUserIds, userId) - : (post.taggedUserIds ?? []).map((id: Types.ObjectId | string) => new Types.ObjectId(id.toString())); + : (post.taggedUserIds ?? []).map( + (id: Types.ObjectId | string) => new Types.ObjectId(id.toString()), + ); const nextCollaboratorIds = typeof dto.collaboratorIds !== 'undefined' ? await this.normalizeUserIdList(dto.collaboratorIds, userId, 5, 'collaborator') - : (post.collaboratorIds ?? []).map((id: Types.ObjectId | string) => new Types.ObjectId(id.toString())); + : (post.collaboratorIds ?? []).map( + (id: Types.ObjectId | string) => new Types.ObjectId(id.toString()), + ); const nextImageItems = this.buildImageItems( nextImageUrls, dto.imageCaptions, dto.imageAltTexts, - hasImageUpdate ? [] : post.imageItems ?? [], + hasImageUpdate ? [] : (post.imageItems ?? []), ); const previousMentionUsernames = this.normalizeMentionUsernames(post.mentionUsernames ?? []); const shouldRecomputeMentions = @@ -334,12 +379,15 @@ export class PostsService { mentionUsernames: previousMentionUsernames, mentionedUsers: [] as Array<{ id: string; username: string }>, }; - const { location: nextLocation, latitude: nextLatitude, longitude: nextLongitude } = - this.normalizeLocation(dto, { - location: post.location ?? '', - latitude: post.latitude ?? null, - longitude: post.longitude ?? null, - }); + const { + location: nextLocation, + latitude: nextLatitude, + longitude: nextLongitude, + } = this.normalizeLocation(dto, { + location: post.location ?? '', + latitude: post.latitude ?? null, + longitude: post.longitude ?? null, + }); if (!nextContent && !nextImageUrls.length && !nextVideoUrl && !nextAudioUrl) { throw new BadRequestException('Post must contain caption or media'); } @@ -356,6 +404,7 @@ export class PostsService { }, { audioSourceBuffer: audioFile?.buffer, + extractedDurationSeconds: savedVideoUpload?.durationSeconds ?? uploadedAudioDurationSeconds, waveformSeed: nextAudioUrl || nextContent || post.id, thumbnailUrl: uploadedThumbnailUrl, }, @@ -427,7 +476,9 @@ export class PostsService { await Promise.all([ ...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)), uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(), - uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(), + uploadedHlsUrl + ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) + : Promise.resolve(), uploadedThumbnailUrl ? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants) : Promise.resolve(), @@ -442,7 +493,9 @@ export class PostsService { await Promise.all([ ...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)), uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(), - uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(), + uploadedHlsUrl + ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) + : Promise.resolve(), uploadedThumbnailUrl ? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants) : Promise.resolve(), @@ -471,7 +524,10 @@ export class PostsService { } if (hasImageUpdate) { const nextImageSet = new Set(updated.imageUrls ?? []); - const existingImageAssets = this.buildSavedImageAssets(post.imageUrls ?? [], existingImageVariants); + const existingImageAssets = this.buildSavedImageAssets( + post.imageUrls ?? [], + existingImageVariants, + ); await Promise.all( existingImageAssets .filter((asset) => !nextImageSet.has(asset.primaryUrl)) @@ -692,7 +748,10 @@ export class PostsService { }); } - async registerView(userId: string, postId: string): Promise<{ success: true; postId: string; viewCount: number }> { + async registerView( + userId: string, + postId: string, + ): Promise<{ success: true; postId: string; viewCount: number }> { const post = await this.postsRepository.findById(postId); if (!post) { throw new NotFoundException('Post not found'); @@ -771,14 +830,18 @@ export class PostsService { ): Promise { await this.assertPostOwner(userId, postId); const updated = await this.postsRepository.updateById(postId, { - ...(typeof dto.commentsDisabled === 'boolean' ? { commentsDisabled: dto.commentsDisabled } : {}), + ...(typeof dto.commentsDisabled === 'boolean' + ? { commentsDisabled: dto.commentsDisabled } + : {}), ...(typeof dto.commentsFollowersOnly === 'boolean' ? { commentsFollowersOnly: dto.commentsFollowersOnly } : {}), ...(Array.isArray(dto.commentFilterKeywords) ? { commentFilterKeywords: Array.from( - new Set(dto.commentFilterKeywords.map((item) => item.trim().toLowerCase()).filter(Boolean)), + new Set( + dto.commentFilterKeywords.map((item) => item.trim().toLowerCase()).filter(Boolean), + ), ).slice(0, 50), } : {}), @@ -809,7 +872,10 @@ export class PostsService { async archive(userId: string, postId: string): Promise { await this.assertPostOwner(userId, postId); - const updated = await this.postsRepository.updateById(postId, { isArchived: true, pinnedToProfile: false }); + const updated = await this.postsRepository.updateById(postId, { + isArchived: true, + pinnedToProfile: false, + }); if (!updated) { throw new NotFoundException('Post not found'); } @@ -896,7 +962,11 @@ export class PostsService { return updated; } - private resolvePostType(imageUrls: string[] = [], videoUrl?: string, audioUrl?: string): PostType { + private resolvePostType( + imageUrls: string[] = [], + videoUrl?: string, + audioUrl?: string, + ): PostType { const hasImages = imageUrls.length > 0; const hasVideo = !!videoUrl?.trim(); const hasAudio = !!audioUrl?.trim(); @@ -933,6 +1003,7 @@ export class PostsService { }, options: { audioSourceBuffer?: Buffer; + extractedDurationSeconds?: number | null; waveformSeed?: string; thumbnailUrl?: string; } = {}, @@ -942,7 +1013,9 @@ export class PostsService { if (!supportsMediaMetadata) { if (this.hasMediaMetadataInput(dto)) { - throw new BadRequestException('Audio/video metadata is allowed only for audio or video posts'); + throw new BadRequestException( + 'Audio/video metadata is allowed only for audio or video posts', + ); } return { durationSeconds: null, @@ -960,7 +1033,11 @@ export class PostsService { return { durationSeconds: - typeof dto.durationSeconds === 'number' ? dto.durationSeconds : fallback.durationSeconds, + typeof options.extractedDurationSeconds === 'number' + ? options.extractedDurationSeconds + : typeof dto.durationSeconds === 'number' + ? dto.durationSeconds + : fallback.durationSeconds, thumbnailUrl: typeof dto.thumbnailUrl === 'string' ? dto.thumbnailUrl.trim() @@ -1036,7 +1113,10 @@ export class PostsService { const users = await this.usersRepository.findByUsernames(mergedMentionUsernames); const userByUsername = new Map( - users.map((user) => [user.username.toLowerCase(), { id: user.id, username: user.username.toLowerCase() }]), + users.map((user) => [ + user.username.toLowerCase(), + { id: user.id, username: user.username.toLowerCase() }, + ]), ); const mentionedUsers = mergedMentionUsernames @@ -1088,8 +1168,14 @@ export class PostsService { ): PostImageItem[] { return imageUrls.map((url, index) => ({ url, - caption: typeof captions?.[index] === 'string' ? captions[index].trim() : fallback[index]?.caption ?? '', - altText: typeof altTexts?.[index] === 'string' ? altTexts[index].trim() : fallback[index]?.altText ?? '', + caption: + typeof captions?.[index] === 'string' + ? captions[index].trim() + : (fallback[index]?.caption ?? ''), + altText: + typeof altTexts?.[index] === 'string' + ? altTexts[index].trim() + : (fallback[index]?.altText ?? ''), order: index, })); } @@ -1187,10 +1273,15 @@ export class PostsService { await Promise.all( mentionedUsers.map(async (mentionedUser) => { try { - await this.notificationsService.createMentionNotification(actorId, mentionedUser.id, postId, { - resourceType: 'post', - previewText: content.slice(0, 140), - }); + await this.notificationsService.createMentionNotification( + actorId, + mentionedUser.id, + postId, + { + resourceType: 'post', + previewText: content.slice(0, 140), + }, + ); } catch (error) { this.logger.warn( `Mention notification failed for actor=${actorId} recipient=${mentionedUser.id}: ${ @@ -1206,6 +1297,10 @@ export class PostsService { this.validateMediaFile('video', file); const optimized = await this.videoProcessingService.optimizeForPlayback(file); const extension = this.validateMediaFile('video', optimized.file); + const durationSeconds = await this.mediaProbeService.extractDurationSecondsFromBuffer( + optimized.file.buffer, + optimized.file, + ); let videoUrl = ''; let hlsUrl = ''; @@ -1241,18 +1336,24 @@ export class PostsService { thumbnailVariants = savedThumbnail.variants; } - return { videoUrl, hlsUrl, thumbnailUrl, thumbnailVariants }; + return { videoUrl, hlsUrl, thumbnailUrl, thumbnailVariants, durationSeconds }; } catch (error) { await Promise.all([ videoUrl ? this.deleteManagedPostMedia(videoUrl) : Promise.resolve(), hlsUrl ? this.storageService.deleteContainingDirectory(hlsUrl) : Promise.resolve(), - thumbnailUrl ? this.deleteThumbnailAsset(thumbnailUrl, thumbnailVariants) : Promise.resolve(), + thumbnailUrl + ? this.deleteThumbnailAsset(thumbnailUrl, thumbnailVariants) + : Promise.resolve(), ]); throw error; } } - async createRepost(userId: string, sourcePostId: string, dto: CreateRepostDto): Promise { + async createRepost( + userId: string, + sourcePostId: string, + dto: CreateRepostDto, + ): Promise { const sourcePost = await this.postsRepository.findById(sourcePostId); if (!sourcePost) { throw new NotFoundException('Source post not found'); @@ -1289,8 +1390,7 @@ export class PostsService { file: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, ): Promise { const extension = this.validateMediaFile(mediaType, file); - const folder = - mediaType === 'image' ? 'images' : mediaType === 'video' ? 'videos' : 'audios'; + const folder = mediaType === 'image' ? 'images' : mediaType === 'video' ? 'videos' : 'audios'; return this.storageService.saveFile({ folderSegments: ['posts', folder], extension, @@ -1310,8 +1410,8 @@ export class PostsService { mediaType === 'image' ? 'imageFiles must be jpg, jpeg, png, webp, or gif' : mediaType === 'video' - ? 'videoFile must be mp4, mov, webm, mkv, or avi' - : 'audioFile must be mp3, wav, m4a, aac, ogg, or webm', + ? 'videoFile must be mp4, mov, webm, mkv, or avi' + : 'audioFile must be mp3, wav, m4a, aac, ogg, or webm', ); } @@ -1326,8 +1426,8 @@ export class PostsService { mediaType === 'image' ? 'Each image must be 10MB or less' : mediaType === 'video' - ? 'videoFile size must be 100MB or less' - : 'audioFile size must be 20MB or less', + ? 'videoFile size must be 100MB or less' + : 'audioFile size must be 20MB or less', ); } @@ -1539,7 +1639,8 @@ export class PostsService { thumbnailVariants?.originalUrl || ''; - const hasManagedVariantGroup = !!thumbnailVariants && + const hasManagedVariantGroup = + !!thumbnailVariants && [ thumbnailVariants.originalUrl, thumbnailVariants.lowUrl,