Featured artworks thumbnails
This commit is contained in:
217
app/Services/News/NewsCoverImageService.php
Normal file
217
app/Services/News/NewsCoverImageService.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user