Add S3 CDN media readiness and cache health checks
هذا الالتزام موجود في:
@@ -60,6 +60,9 @@ S3_ENDPOINT=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
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_FFMPEG_PATH=ffmpeg
|
||||
IMAGE_PROCESSING_LOW_WIDTH=360
|
||||
|
||||
@@ -89,6 +89,8 @@ export default () => ({
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? '',
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? '',
|
||||
forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? 'false').toLowerCase() === 'true',
|
||||
healthWriteTestEnabled:
|
||||
(process.env.S3_HEALTH_WRITE_TEST_ENABLED ?? 'false').toLowerCase() === 'true',
|
||||
},
|
||||
},
|
||||
imageProcessing: {
|
||||
|
||||
@@ -61,6 +61,7 @@ export const validationSchema = Joi.object({
|
||||
S3_ACCESS_KEY_ID: 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_HEALTH_WRITE_TEST_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||
IMAGE_PROCESSING_ENABLED: Joi.boolean().truthy('true').falsy('false').optional(),
|
||||
IMAGE_PROCESSING_FFMPEG_PATH: Joi.string().allow('').optional(),
|
||||
IMAGE_PROCESSING_LOW_WIDTH: Joi.number().min(160).max(1920).default(360),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DeleteObjectCommand,
|
||||
DeleteObjectsCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { Upload } from '@aws-sdk/lib-storage';
|
||||
@@ -42,6 +43,7 @@ export class ManagedStorageService implements OnModuleDestroy {
|
||||
Key: objectKey,
|
||||
Body: params.buffer,
|
||||
ContentType: params.contentType || undefined,
|
||||
CacheControl: this.resolveCacheControl(objectKey),
|
||||
},
|
||||
});
|
||||
await upload.done();
|
||||
@@ -95,6 +97,7 @@ export class ManagedStorageService implements OnModuleDestroy {
|
||||
Key: entry.objectKey,
|
||||
Body: entry.buffer,
|
||||
ContentType: entry.contentType || undefined,
|
||||
CacheControl: this.resolveCacheControl(entry.objectKey),
|
||||
},
|
||||
});
|
||||
await upload.done();
|
||||
@@ -242,6 +245,8 @@ export class ManagedStorageService implements OnModuleDestroy {
|
||||
health.uploadPathExists = exists;
|
||||
health.uploadPathReadable = readable;
|
||||
health.uploadPathWritable = writable;
|
||||
} else if (provider === 's3') {
|
||||
health.s3 = await this.getS3Health();
|
||||
}
|
||||
|
||||
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> {
|
||||
try {
|
||||
await access(path, mode);
|
||||
|
||||
@@ -53,6 +53,13 @@ export class MediaService {
|
||||
if (storageProvider === 's3' && !(storageHealth.isS3Configured ?? storageHealth.s3Configured)) {
|
||||
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 =
|
||||
storageProvider !== 'local' || storageHealth.uploadPathWritable !== false;
|
||||
@@ -80,6 +87,8 @@ export class MediaService {
|
||||
rangeRequests: true,
|
||||
immutableCacheSeconds: 31536000,
|
||||
hlsManifestCacheSeconds: 300,
|
||||
s3ImmutableCacheControl: 'public, max-age=31536000, immutable',
|
||||
s3HlsManifestCacheControl: 'public, max-age=300',
|
||||
},
|
||||
staticServing: {
|
||||
uploadsPublicPath:
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم