Files
SkinbaseNova/app/Services/News/NewsCoverImageService.php

217 lines
6.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\News;
use App\Support\News\NewsCoverImage;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException;
final class NewsCoverImageService
{
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
private const MAX_FILE_SIZE_KB = 6144;
private const MAX_WIDTH = 2200;
private const MAX_HEIGHT = 1400;
private const MIN_WIDTH = 1200;
private const MIN_HEIGHT = 630;
private ?ImageManager $manager = null;
public function __construct()
{
try {
$this->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.');
}
}
}