Harden media health checks and duration extraction
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled

هذا الالتزام موجود في:
boutmoun123
2026-05-31 18:53:07 +03:00
الأصل 1973b8b904
التزام 637782aed6
11 ملفات معدلة مع 587 إضافات و138 حذوفات

عرض الملف

@@ -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

عرض الملف

@@ -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 <Icon className="h-4 w-4" />;
}
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<DashboardSnapshot>({
@@ -417,26 +454,33 @@ export default function DashboardPage() {
}
>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Recent activity</CardTitle>
<CardTitle>Platform activity</CardTitle>
<Link href="/analytics" className="text-sm text-primary">
Activity feeds
View analytics
</Link>
</CardHeader>
<CardContent>
{!snapshot.recentActivity.length ? (
<EmptyState
title="No recent activity"
title="No platform activity"
description="Operational and moderation activity will appear here."
/>
) : (
<div className="space-y-3">
{snapshot.recentActivity.map((item, index) => (
{snapshot.recentActivity.slice(0, 5).map((item, index) => (
<div
key={`${item.type}-${index}`}
className="rounded-xl border border-border/70 bg-secondary/20 p-3"
className="rounded-lg border border-border/70 bg-secondary/20 p-3 transition hover:border-primary/40 hover:bg-secondary/30"
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-foreground">{item.title}</div>
<div className="flex min-w-0 items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-background/50 text-primary">
<ActivityTypeIcon type={item.type} />
</span>
<div dir="auto" className="truncate text-sm font-semibold text-foreground">
{item.title || "Untitled activity"}
</div>
</div>
<Badge
variant={
item.status === "flagged" || item.status === "disabled"
@@ -444,11 +488,11 @@ export default function DashboardPage() {
: "muted"
}
>
{item.type}
{getActivityLabel(item.type)}
</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.subtitle} {formatDateTime(item.createdAt)}
<div dir="auto" className="mt-2 truncate text-xs text-muted-foreground">
{item.subtitle || "No additional details"} - {formatDateTime(item.createdAt)}
</div>
</div>
))}

عرض الملف

@@ -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;

عرض الملف

@@ -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',

عرض الملف

@@ -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),

عرض الملف

@@ -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
return (
(this.configService.get<string>('storage.provider', { infer: true }) as
| 'local'
| 's3'
| undefined) ?? 'local';
| 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();

عرض الملف

@@ -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

عرض الملف

@@ -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() {
return {
storage: await this.storageService.getHealth(),
processing: {
imageProcessingEnabled:
this.configService.get<boolean>('imageProcessing.enabled', { infer: true }) ?? false,
videoProcessingEnabled:
this.configService.get<boolean>('videoProcessing.enabled', { infer: true }) ?? false,
videoHlsGenerationEnabled:
this.configService.get<boolean>('videoProcessing.generateHls', { infer: true }) ?? true,
videoThumbnailGenerationEnabled:
const [storageHealth, ffmpeg, ffprobe] = await Promise.all([
this.storageService.getHealth(),
this.mediaProbeService.checkFfmpeg(),
this.mediaProbeService.checkFfprobe(),
]);
const storageProvider =
this.configService.get<string>('storage.provider', { infer: true }) ?? 'local';
const publicBaseUrl = this.configService.get<string>('publicBaseUrl', { infer: true }) ?? '';
const warnings: string[] = [];
const imageProcessingEnabled =
this.configService.get<boolean>('imageProcessing.enabled', { infer: true }) ?? false;
const videoProcessingEnabled =
this.configService.get<boolean>('videoProcessing.enabled', { infer: true }) ?? false;
const videoHlsGenerationEnabled =
this.configService.get<boolean>('videoProcessing.generateHls', { infer: true }) ?? true;
const videoThumbnailGenerationEnabled =
this.configService.get<boolean>('videoProcessing.generateThumbnails', { infer: true }) ??
true,
ffmpegPath:
this.configService.get<string>('videoProcessing.ffmpegPath', { infer: true }) ?? 'ffmpeg',
true;
const audioProcessingEnabled =
this.configService.get<boolean>('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 {
status,
storage: storageHealth,
processing: {
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');

عرض الملف

@@ -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
: (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,8 +379,11 @@ export class PostsService {
mentionUsernames: previousMentionUsernames,
mentionedUsers: [] as Array<{ id: string; username: string }>,
};
const { location: nextLocation, latitude: nextLatitude, longitude: nextLongitude } =
this.normalizeLocation(dto, {
const {
location: nextLocation,
latitude: nextLatitude,
longitude: nextLongitude,
} = this.normalizeLocation(dto, {
location: post.location ?? '',
latitude: post.latitude ?? null,
longitude: post.longitude ?? null,
@@ -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<PostDocument> {
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<PostDocument> {
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, {
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<PostDocument> {
async createRepost(
userId: string,
sourcePostId: string,
dto: CreateRepostDto,
): Promise<PostDocument> {
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<string> {
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,
@@ -1539,7 +1639,8 @@ export class PostsService {
thumbnailVariants?.originalUrl ||
'';
const hasManagedVariantGroup = !!thumbnailVariants &&
const hasManagedVariantGroup =
!!thumbnailVariants &&
[
thumbnailVariants.originalUrl,
thumbnailVariants.lowUrl,