Add S3 CDN media readiness and cache health checks
هذا الالتزام موجود في:
@@ -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:
|
||||||
|
|||||||
المرجع في مشكلة جديدة
حظر مستخدم