Profile: store covers in object storage (WebP); add covers config; remember artworks categories content-type preference

This commit is contained in:
2026-03-29 09:22:36 +02:00
parent cab4fbd83e
commit 1da7d3bf88
27 changed files with 703 additions and 448 deletions

View File

@@ -7,11 +7,9 @@ use App\Support\CoverUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
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\JpegEncoder;
use Intervention\Image\Encoders\PngEncoder;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException;
@@ -142,9 +140,9 @@ class ProfileCoverController extends Controller
]);
}
private function storageRoot(): string
private function coverDiskName(): string
{
return rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR);
return (string) config('covers.disk', 's3');
}
private function coverDirectory(string $hash): string
@@ -152,15 +150,12 @@ class ProfileCoverController extends Controller
$p1 = substr($hash, 0, 2);
$p2 = substr($hash, 2, 2);
return $this->storageRoot()
. DIRECTORY_SEPARATOR . 'covers'
. DIRECTORY_SEPARATOR . $p1
. DIRECTORY_SEPARATOR . $p2;
return 'covers/' . $p1 . '/' . $p2;
}
private function coverPath(string $hash, string $ext): string
{
return $this->coverDirectory($hash) . DIRECTORY_SEPARATOR . $hash . '.' . $ext;
return $this->coverDirectory($hash) . '/' . $hash . '.' . $ext;
}
/**
@@ -169,6 +164,7 @@ class ProfileCoverController extends Controller
private function storeCoverFile(UploadedFile $file): array
{
$this->assertImageManager();
$this->assertStorageIsAllowed();
$uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
if ($uploadPath === '' || ! is_readable($uploadPath)) {
@@ -201,18 +197,26 @@ class ProfileCoverController extends Controller
));
}
$ext = $mime === 'image/jpeg' ? 'jpg' : ($mime === 'image/png' ? 'png' : 'webp');
$image = $this->manager->read($raw);
$processed = $image->cover(self::TARGET_WIDTH, self::TARGET_HEIGHT, 'center');
$ext = 'webp';
$encoded = $this->encodeByExtension($processed, $ext);
$hash = hash('sha256', $encoded);
$dir = $this->coverDirectory($hash);
if (! File::exists($dir)) {
File::makeDirectory($dir, 0755, true);
}
$disk = Storage::disk($this->coverDiskName());
$written = $disk->put($this->coverPath($hash, $ext), $encoded, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => match ($ext) {
'jpg' => 'image/jpeg',
'png' => 'image/png',
default => 'image/webp',
},
]);
File::put($this->coverPath($hash, $ext), $encoded);
if ($written !== true) {
throw new RuntimeException('Unable to store cover image in object storage.');
}
return ['hash' => $hash, 'ext' => $ext];
}
@@ -220,8 +224,6 @@ class ProfileCoverController extends Controller
private function encodeByExtension($image, string $ext): string
{
return match ($ext) {
'jpg' => (string) $image->encode(new JpegEncoder(85)),
'png' => (string) $image->encode(new PngEncoder()),
default => (string) $image->encode(new WebpEncoder(85)),
};
}
@@ -235,10 +237,7 @@ class ProfileCoverController extends Controller
return;
}
$path = $this->coverPath($trimHash, $trimExt);
if (is_file($path)) {
@unlink($path);
}
Storage::disk($this->coverDiskName())->delete($this->coverPath($trimHash, $trimExt));
}
private function assertImageManager(): void
@@ -249,4 +248,16 @@ class ProfileCoverController extends Controller
throw new RuntimeException('Image processing is not available on this environment.');
}
private function assertStorageIsAllowed(): void
{
if (! app()->environment('production')) {
return;
}
$diskName = $this->coverDiskName();
if (in_array($diskName, ['local', 'public'], true)) {
throw new RuntimeException('Production cover storage must use object storage, not local/public disks.');
}
}
}