Files
SkinbaseNova/app/Services/AvatarService.php
2026-02-22 17:09:34 +01:00

268 lines
9.3 KiB
PHP

<?php
namespace App\Services;
use App\Models\UserProfile;
use Carbon\Carbon;
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;
class AvatarService
{
private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
protected $sizes = [
'xs' => 32,
'sm' => 64,
'md' => 128,
'lg' => 256,
'xl' => 512,
];
protected $quality = 85;
private ?ImageManager $manager = null;
public function __construct()
{
$configuredSizes = array_values(array_filter((array) config('avatars.sizes', [32, 64, 128, 256, 512]), static fn ($size) => (int) $size > 0));
if ($configuredSizes !== []) {
$this->sizes = array_fill_keys(array_map('strval', $configuredSizes), null);
$this->sizes = array_combine(array_keys($this->sizes), $configuredSizes);
}
$this->quality = (int) config('avatars.quality', 85);
try {
$this->manager = extension_loaded('gd')
? new ImageManager(new GdDriver())
: new ImageManager(new ImagickDriver());
} catch (\Throwable $e) {
logger()->warning('Avatar image manager configuration failed: ' . $e->getMessage());
$this->manager = null;
}
}
public function storeFromUploadedFile(int $userId, UploadedFile $file): string
{
$this->assertImageManagerAvailable();
$this->assertStorageIsAllowed();
$binary = $this->assertSecureImageUpload($file);
return $this->storeFromBinary($userId, $binary);
}
public function storeFromLegacyFile(int $userId, string $path): ?string
{
$this->assertImageManagerAvailable();
$this->assertStorageIsAllowed();
if (!file_exists($path) || !is_readable($path)) {
return null;
}
$binary = file_get_contents($path);
if ($binary === false || $binary === '') {
return null;
}
return $this->storeFromBinary($userId, $binary);
}
private function storeFromBinary(int $userId, string $binary): string
{
$image = $this->readImageFromBinary($binary);
$image = $this->normalizeImage($image);
$diskName = (string) config('avatars.disk', 's3');
$disk = Storage::disk($diskName);
$basePath = "avatars/{$userId}";
$hashSeed = '';
foreach ($this->sizes as $size) {
$variant = $image->cover($size, $size);
$encoded = (string) $variant->encode(new WebpEncoder($this->quality));
$disk->put("{$basePath}/{$size}.webp", $encoded, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => 'image/webp',
]);
if ($size === 128) {
$hashSeed = $encoded;
}
}
if ($hashSeed === '') {
throw new RuntimeException('Avatar processing failed to generate a hash seed.');
}
$hash = hash('sha256', $hashSeed);
$this->updateProfileMetadata($userId, $hash);
return $hash;
}
private function normalizeImage($image)
{
try {
$core = $image->getCore();
$isImagickCore = is_object($core) && strtolower(get_class($core)) === 'imagick';
if ($isImagickCore) {
try {
$core->stripImage();
} catch (\Throwable $_) {
}
try {
$colorSpaceRgb = defined('\\Imagick::COLORSPACE_RGB') ? constant('\\Imagick::COLORSPACE_RGB') : null;
$colorSpaceSRgb = defined('\\Imagick::COLORSPACE_SRGB') ? constant('\\Imagick::COLORSPACE_SRGB') : null;
if (is_int($colorSpaceRgb)) {
$core->setImageColorspace($colorSpaceRgb);
} elseif (is_int($colorSpaceSRgb)) {
$core->setImageColorspace($colorSpaceSRgb);
}
} catch (\Throwable $_) {
}
try {
$alphaRemove = defined('\\Imagick::ALPHACHANNEL_REMOVE') ? constant('\\Imagick::ALPHACHANNEL_REMOVE') : null;
if (is_int($alphaRemove)) {
$core->setImageAlphaChannel($alphaRemove);
}
} catch (\Throwable $_) {
}
try {
$core->setBackgroundColor('white');
$layerFlatten = defined('\\Imagick::LAYERMETHOD_FLATTEN') ? constant('\\Imagick::LAYERMETHOD_FLATTEN') : null;
$flattened = is_int($layerFlatten) ? $core->mergeImageLayers($layerFlatten) : null;
if (is_object($flattened) && strtolower(get_class($flattened)) === 'imagick') {
$core->clear();
$core->destroy();
$image = $this->manager->read((string) $flattened->getImageBlob());
}
} catch (\Throwable $_) {
}
return $image;
}
$isGdCore = is_resource($core) || (is_object($core) && strtolower(get_class($core)) === 'gdimage');
if ($isGdCore) {
$width = imagesx($core);
$height = imagesy($core);
if ($width > 0 && $height > 0) {
$flattened = imagecreatetruecolor($width, $height);
if ($flattened !== false) {
$white = imagecolorallocate($flattened, 255, 255, 255);
imagefilledrectangle($flattened, 0, 0, $width, $height, $white);
imagecopy($flattened, $core, 0, 0, 0, 0, $width, $height);
ob_start();
imagepng($flattened);
$pngBinary = (string) ob_get_clean();
imagedestroy($flattened);
if ($pngBinary !== '') {
return $this->manager->read($pngBinary);
}
}
}
}
} catch (\Throwable $_) {
}
return $image;
}
private function readImageFromBinary(string $binary)
{
try {
return $this->manager->read($binary);
} catch (\Throwable $e) {
throw new RuntimeException('Failed to decode uploaded image.');
}
}
private function updateProfileMetadata(int $userId, string $hash): void
{
UserProfile::query()->updateOrCreate(
['user_id' => $userId],
[
'avatar_hash' => $hash,
'avatar_mime' => 'image/webp',
'avatar_updated_at' => Carbon::now(),
]
);
}
private function assertImageManagerAvailable(): void
{
if ($this->manager !== null) {
return;
}
throw new RuntimeException('Avatar image processing is not available on this environment.');
}
private function assertStorageIsAllowed(): void
{
if (!app()->environment('production')) {
return;
}
$diskName = (string) config('avatars.disk', 's3');
if (in_array($diskName, ['local', 'public'], true)) {
throw new RuntimeException('Production avatar storage must use object storage, not local/public disks.');
}
}
private function assertSecureImageUpload(UploadedFile $file): string
{
if (! $file->isValid()) {
throw new RuntimeException('Avatar upload is not valid.');
}
$extension = strtolower((string) $file->getClientOriginalExtension());
if (!in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
throw new RuntimeException('Unsupported avatar file extension.');
}
$detectedMime = (string) $file->getMimeType();
if (!in_array($detectedMime, self::ALLOWED_MIME_TYPES, true)) {
throw new RuntimeException('Unsupported avatar MIME type.');
}
$uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
if ($uploadPath === '' || !is_readable($uploadPath)) {
throw new RuntimeException('Unable to resolve uploaded avatar path.');
}
$binary = file_get_contents($uploadPath);
if ($binary === false || $binary === '') {
throw new RuntimeException('Unable to read uploaded avatar data.');
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$finfoMime = (string) $finfo->buffer($binary);
if (!in_array($finfoMime, self::ALLOWED_MIME_TYPES, true)) {
throw new RuntimeException('Avatar content did not match allowed image MIME types.');
}
$dimensions = @getimagesizefromstring($binary);
if (!is_array($dimensions) || ($dimensions[0] ?? 0) < 1 || ($dimensions[1] ?? 0) < 1) {
throw new RuntimeException('Uploaded avatar is not a valid image.');
}
return $binary;
}
}