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 $position = 'center'): string { $this->assertImageManagerAvailable(); $this->assertStorageIsAllowed(); $binary = $this->assertSecureImageUpload($file); return $this->storeFromBinary($userId, $binary, $position); } 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); } public function removeAvatar(int $userId): void { $diskName = (string) config('avatars.disk', 's3'); Storage::disk($diskName)->deleteDirectory("avatars/{$userId}"); UserProfile::query()->updateOrCreate( ['user_id' => $userId], [ 'avatar_hash' => null, 'avatar_mime' => null, 'avatar_updated_at' => Carbon::now(), ] ); } private function storeFromBinary(int $userId, string $binary, string $position = 'center'): string { $image = $this->readImageFromBinary($binary); $image = $this->normalizeImage($image); $cropPosition = $this->normalizePosition($position); $diskName = (string) config('avatars.disk', 's3'); $disk = Storage::disk($diskName); $basePath = "avatars/{$userId}"; $hashSeed = ''; foreach ($this->sizes as $size) { $variant = $image->cover($size, $size, $cropPosition); $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 normalizePosition(string $position): string { $normalized = strtolower(trim($position)); if (in_array($normalized, self::ALLOWED_POSITIONS, true)) { return $normalized; } return 'center'; } 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; } }