manager = extension_loaded('gd') ? new ImageManager(new GdDriver()) : new ImageManager(new ImagickDriver()); } catch (\Throwable) { $this->manager = null; } } public function maxFileSizeKb(): int { return self::MAX_FILE_SIZE_KB; } public function storeUploadedFile(UploadedFile $file): array { $this->assertImageManager(); $this->assertStorageIsAllowed(); $raw = $this->readUploadBytes($file); $this->assertSupportedMimeType($raw); $this->assertMinimumDimensions($raw); $masterImage = $this->manager->read($raw)->scaleDown(width: self::MAX_WIDTH, height: self::MAX_HEIGHT); $masterEncoded = (string) $masterImage->encode(new WebpEncoder(85)); $path = NewsCoverImage::path(hash('sha256', $masterEncoded)); $this->writeImage($path, $masterEncoded); foreach (NewsCoverImage::VARIANTS as $variant => $config) { $variantImage = $this->manager->read($raw)->scaleDown(width: (int) $config['width']); $variantEncoded = (string) $variantImage->encode(new WebpEncoder((int) $config['quality'])); $this->writeImage(NewsCoverImage::variantPath($path, $variant), $variantEncoded); } return [ 'path' => $path, 'url' => NewsCoverImage::url($path), 'width' => (int) $masterImage->width(), 'height' => (int) $masterImage->height(), 'size_bytes' => strlen($masterEncoded), 'mobile_url' => NewsCoverImage::variantUrl($path, 'mobile'), 'desktop_url' => NewsCoverImage::variantUrl($path, 'desktop'), 'srcset' => NewsCoverImage::srcset($path), ]; } public function ensureVariants(string $path, bool $force = false): array { $trimmed = NewsCoverImage::normalizePath($path); if (! NewsCoverImage::isManagedPath($trimmed)) { return ['generated' => 0, 'skipped' => count(NewsCoverImage::VARIANTS)]; } $this->assertImageManager(); $this->assertStorageIsAllowed(); $disk = Storage::disk($this->mediaDiskName()); if (! $disk->exists($trimmed)) { throw new RuntimeException('Managed cover image is missing from object storage.'); } $raw = $disk->get($trimmed); if (! is_string($raw) || $raw === '') { throw new RuntimeException('Unable to read managed cover image from object storage.'); } $generated = 0; $skipped = 0; foreach (NewsCoverImage::VARIANTS as $variant => $config) { $variantPath = NewsCoverImage::variantPath($trimmed, $variant); if (! $force && $disk->exists($variantPath)) { $skipped++; continue; } $variantImage = $this->manager->read($raw)->scaleDown(width: (int) $config['width']); $variantEncoded = (string) $variantImage->encode(new WebpEncoder((int) $config['quality'])); $this->writeImage($variantPath, $variantEncoded); $generated++; } return ['generated' => $generated, 'skipped' => $skipped]; } public function deleteManagedFiles(string $path): void { $paths = NewsCoverImage::managedPaths($path); if ($paths === []) { return; } Storage::disk($this->mediaDiskName())->delete($paths); } private function mediaDiskName(): string { return (string) config('uploads.object_storage.disk', 's3'); } private function writeImage(string $path, string $encoded): void { $written = Storage::disk($this->mediaDiskName())->put($path, $encoded, [ 'visibility' => 'public', 'CacheControl' => 'public, max-age=31536000, immutable', 'ContentType' => 'image/webp', ]); if ($written !== true) { throw new RuntimeException('Unable to store image in object storage.'); } } private function readUploadBytes(UploadedFile $file): string { $uploadPath = (string) ($file->getRealPath() ?: $file->getPathname()); if ($uploadPath === '' || ! is_readable($uploadPath)) { throw new RuntimeException('Unable to resolve uploaded image path.'); } $raw = file_get_contents($uploadPath); if ($raw === false || $raw === '') { throw new RuntimeException('Unable to read uploaded image.'); } return $raw; } private function assertSupportedMimeType(string $raw): void { $finfo = new \finfo(FILEINFO_MIME_TYPE); $mime = strtolower((string) $finfo->buffer($raw)); if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) { throw new RuntimeException('Unsupported image mime type.'); } } private function assertMinimumDimensions(string $raw): void { $size = @getimagesizefromstring($raw); if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) { throw new RuntimeException('Uploaded file is not a valid image.'); } $width = (int) ($size[0] ?? 0); $height = (int) ($size[1] ?? 0); if ($width < self::MIN_WIDTH || $height < self::MIN_HEIGHT) { throw new RuntimeException(sprintf( 'Image is too small. Minimum required size is %dx%d.', self::MIN_WIDTH, self::MIN_HEIGHT, )); } } private function assertImageManager(): void { if ($this->manager !== null) { return; } throw new RuntimeException('Image processing is not available on this environment.'); } private function assertStorageIsAllowed(): void { if (! app()->environment('production')) { return; } $diskName = $this->mediaDiskName(); if (in_array($diskName, ['local', 'public'], true)) { throw new RuntimeException('Production news media storage must use object storage, not local/public disks.'); } } }