262 lines
9.1 KiB
PHP
262 lines
9.1 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();
|
|
$this->assertSecureImageUpload($file);
|
|
|
|
$binary = file_get_contents($file->getRealPath());
|
|
if ($binary === false || $binary === '') {
|
|
throw new RuntimeException('Uploaded avatar file is empty or unreadable.');
|
|
}
|
|
|
|
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): void
|
|
{
|
|
$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.');
|
|
}
|
|
|
|
$binary = file_get_contents($file->getRealPath());
|
|
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.');
|
|
}
|
|
}
|
|
}
|