diff --git a/.env.example b/.env.example index e9150d2..41aac03 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 1b19220..fe51f9b 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -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: { diff --git a/src/config/validation.schema.ts b/src/config/validation.schema.ts index 6e9d765..d973607 100644 --- a/src/config/validation.schema.ts +++ b/src/config/validation.schema.ts @@ -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), diff --git a/src/infrastructure/storage/managed-storage.service.ts b/src/infrastructure/storage/managed-storage.service.ts index ecec386..201c9be 100644 --- a/src/infrastructure/storage/managed-storage.service.ts +++ b/src/infrastructure/storage/managed-storage.service.ts @@ -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> { + const configured = this.hasS3Configuration(); + const writeTestEnabled = + this.configService.get('storage.s3.healthWriteTestEnabled', { infer: true }) ?? + false; + const health: Record = { + configured, + bucket: this.configService.get('storage.s3.bucket', { infer: true }) ?? '', + endpointConfigured: !!( + this.configService.get('storage.s3.endpoint', { infer: true }) ?? '' + ), + publicBaseUrlConfigured: !!( + this.configService.get('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 { try { await access(path, mode); diff --git a/src/modules/media/media.service.ts b/src/modules/media/media.service.ts index 7aa803c..289051f 100644 --- a/src/modules/media/media.service.ts +++ b/src/modules/media/media.service.ts @@ -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 | 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: