217 lines
6.8 KiB
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.');
|
|
}
|
|
}
|
|
} |