Add S3 CDN media readiness and cache health checks

هذا الالتزام موجود في:
boutmoun123
2026-06-07 00:36:32 +03:00
الأصل db8628dc68
التزام 1736cf143f
5 ملفات معدلة مع 94 إضافات و0 حذوفات

عرض الملف

@@ -60,6 +60,9 @@ S3_ENDPOINT=
S3_ACCESS_KEY_ID= S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY= S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=false S3_FORCE_PATH_STYLE=false
# Optional: writes then deletes a tiny health object when GET /media/health runs.
# Keep false unless you explicitly want to verify S3 write permissions.
S3_HEALTH_WRITE_TEST_ENABLED=false
IMAGE_PROCESSING_ENABLED=true IMAGE_PROCESSING_ENABLED=true
IMAGE_PROCESSING_FFMPEG_PATH=ffmpeg IMAGE_PROCESSING_FFMPEG_PATH=ffmpeg
IMAGE_PROCESSING_LOW_WIDTH=360 IMAGE_PROCESSING_LOW_WIDTH=360

عرض الملف

@@ -89,6 +89,8 @@ export default () => ({
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? '', accessKeyId: process.env.S3_ACCESS_KEY_ID ?? '',
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? '', 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',
healthWriteTestEnabled:
(process.env.S3_HEALTH_WRITE_TEST_ENABLED ?? 'false').toLowerCase() === 'true',
}, },
}, },
imageProcessing: { imageProcessing: {

عرض الملف

@@ -61,6 +61,7 @@ export const validationSchema = Joi.object({
S3_ACCESS_KEY_ID: Joi.string().allow('').optional(), S3_ACCESS_KEY_ID: Joi.string().allow('').optional(),
S3_SECRET_ACCESS_KEY: Joi.string().allow('').optional(), S3_SECRET_ACCESS_KEY: Joi.string().allow('').optional(),
S3_FORCE_PATH_STYLE: Joi.boolean().truthy('true').falsy('false').default(false), S3_FORCE_PATH_STYLE: Joi.boolean().truthy('true').falsy('false').default(false),
S3_HEALTH_WRITE_TEST_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
IMAGE_PROCESSING_ENABLED: Joi.boolean().truthy('true').falsy('false').optional(), IMAGE_PROCESSING_ENABLED: Joi.boolean().truthy('true').falsy('false').optional(),
IMAGE_PROCESSING_FFMPEG_PATH: Joi.string().allow('').optional(), IMAGE_PROCESSING_FFMPEG_PATH: Joi.string().allow('').optional(),
IMAGE_PROCESSING_LOW_WIDTH: Joi.number().min(160).max(1920).default(360), IMAGE_PROCESSING_LOW_WIDTH: Joi.number().min(160).max(1920).default(360),

عرض الملف

@@ -4,6 +4,7 @@ import {
DeleteObjectCommand, DeleteObjectCommand,
DeleteObjectsCommand, DeleteObjectsCommand,
ListObjectsV2Command, ListObjectsV2Command,
PutObjectCommand,
S3Client, S3Client,
} from '@aws-sdk/client-s3'; } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage'; import { Upload } from '@aws-sdk/lib-storage';
@@ -42,6 +43,7 @@ export class ManagedStorageService implements OnModuleDestroy {
Key: objectKey, Key: objectKey,
Body: params.buffer, Body: params.buffer,
ContentType: params.contentType || undefined, ContentType: params.contentType || undefined,
CacheControl: this.resolveCacheControl(objectKey),
}, },
}); });
await upload.done(); await upload.done();
@@ -95,6 +97,7 @@ export class ManagedStorageService implements OnModuleDestroy {
Key: entry.objectKey, Key: entry.objectKey,
Body: entry.buffer, Body: entry.buffer,
ContentType: entry.contentType || undefined, ContentType: entry.contentType || undefined,
CacheControl: this.resolveCacheControl(entry.objectKey),
}, },
}); });
await upload.done(); await upload.done();
@@ -242,6 +245,8 @@ export class ManagedStorageService implements OnModuleDestroy {
health.uploadPathExists = exists; health.uploadPathExists = exists;
health.uploadPathReadable = readable; health.uploadPathReadable = readable;
health.uploadPathWritable = writable; health.uploadPathWritable = writable;
} else if (provider === 's3') {
health.s3 = await this.getS3Health();
} }
return health; return health;
@@ -391,6 +396,80 @@ export class ManagedStorageService implements OnModuleDestroy {
); );
} }
private async getS3Health(): Promise<Record<string, unknown>> {
const configured = this.hasS3Configuration();
const writeTestEnabled =
this.configService.get<boolean>('storage.s3.healthWriteTestEnabled', { infer: true }) ??
false;
const health: Record<string, unknown> = {
configured,
bucket: this.configService.get<string>('storage.s3.bucket', { infer: true }) ?? '',
endpointConfigured: !!(
this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? ''
),
publicBaseUrlConfigured: !!(
this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? ''
),
reachable: false,
writeTestEnabled,
writable: undefined,
error: '',
};
if (!configured) {
health.error = 'S3 storage settings are not fully configured';
return health;
}
try {
const client = this.getS3Client();
await client.send(
new ListObjectsV2Command({
Bucket: this.getS3Bucket(),
Prefix: `${this.getBasePath()}/`,
MaxKeys: 1,
}),
);
health.reachable = true;
if (writeTestEnabled) {
const testKey = posix.join(this.getBasePath(), `.media-health-${randomUUID()}.tmp`);
await client.send(
new PutObjectCommand({
Bucket: this.getS3Bucket(),
Key: testKey,
Body: 'ok',
ContentType: 'text/plain',
CacheControl: 'no-store',
}),
);
await client.send(
new DeleteObjectCommand({
Bucket: this.getS3Bucket(),
Key: testKey,
}),
);
health.writable = true;
}
} catch (error) {
health.error = error instanceof Error ? error.message : 'unknown S3 health error';
if (writeTestEnabled) {
health.writable = false;
}
}
return health;
}
private resolveCacheControl(objectKey: string): string {
const extension = objectKey.split('?')[0].split('#')[0].toLowerCase().split('.').pop() ?? '';
if (extension === 'm3u8') {
return 'public, max-age=300';
}
return 'public, max-age=31536000, immutable';
}
private async canAccess(path: string, mode: number): Promise<boolean> { private async canAccess(path: string, mode: number): Promise<boolean> {
try { try {
await access(path, mode); await access(path, mode);

عرض الملف

@@ -53,6 +53,13 @@ export class MediaService {
if (storageProvider === 's3' && !(storageHealth.isS3Configured ?? storageHealth.s3Configured)) { if (storageProvider === 's3' && !(storageHealth.isS3Configured ?? storageHealth.s3Configured)) {
warnings.push('S3 provider selected but missing required env variables'); warnings.push('S3 provider selected but missing required env variables');
} }
if (storageProvider === 's3' && !storageHealth.storagePublicBaseUrlConfigured) {
warnings.push('STORAGE_PUBLIC_BASE_URL is not configured; media may be served from the storage endpoint instead of CDN');
}
const s3Health = storageHealth.s3 as Record<string, unknown> | undefined;
if (storageProvider === 's3' && s3Health?.reachable === false) {
warnings.push('S3 bucket is not reachable from the backend');
}
const storageWritable = const storageWritable =
storageProvider !== 'local' || storageHealth.uploadPathWritable !== false; storageProvider !== 'local' || storageHealth.uploadPathWritable !== false;
@@ -80,6 +87,8 @@ export class MediaService {
rangeRequests: true, rangeRequests: true,
immutableCacheSeconds: 31536000, immutableCacheSeconds: 31536000,
hlsManifestCacheSeconds: 300, hlsManifestCacheSeconds: 300,
s3ImmutableCacheControl: 'public, max-age=31536000, immutable',
s3HlsManifestCacheControl: 'public, max-age=300',
}, },
staticServing: { staticServing: {
uploadsPublicPath: uploadsPublicPath: