Upload beautify
This commit is contained in:
@@ -2,15 +2,22 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Intervention\Image\ImageManagerStatic as Image;
|
||||
use RuntimeException;
|
||||
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,
|
||||
@@ -21,149 +28,234 @@ class AvatarService
|
||||
|
||||
protected $quality = 85;
|
||||
|
||||
private ?ImageManager $manager = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Guard: if Intervention Image is not installed, defer error until actual use
|
||||
if (class_exists(\Intervention\Image\ImageManagerStatic::class)) {
|
||||
try {
|
||||
Image::configure(['driver' => extension_loaded('gd') ? 'gd' : 'imagick']);
|
||||
$this->imageAvailable = true;
|
||||
} catch (\Throwable $e) {
|
||||
// If configuration fails, treat as unavailable and log for diagnostics
|
||||
logger()->warning('Intervention Image present but configuration failed: '.$e->getMessage());
|
||||
$this->imageAvailable = false;
|
||||
}
|
||||
} else {
|
||||
$this->imageAvailable = false;
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an uploaded file for a user and store webp sizes.
|
||||
* Returns the computed sha1 hash.
|
||||
*
|
||||
* @param int $userId
|
||||
* @param UploadedFile $file
|
||||
* @return string sha1 hash
|
||||
*/
|
||||
public function storeFromUploadedFile(int $userId, UploadedFile $file): string
|
||||
{
|
||||
if (! $this->imageAvailable) {
|
||||
throw new RuntimeException('Intervention Image is not available. If you just installed the package, restart your PHP process (php artisan serve or PHP-FPM) and run `composer dump-autoload -o`.');
|
||||
$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.');
|
||||
}
|
||||
|
||||
// Load image and re-encode to webp after validating
|
||||
try {
|
||||
$img = Image::make($file->getRealPath());
|
||||
} catch (\Throwable $e) {
|
||||
throw new RuntimeException('Failed to read uploaded image: '.$e->getMessage());
|
||||
}
|
||||
|
||||
// Ensure square center crop per spec
|
||||
$max = max($img->width(), $img->height());
|
||||
$img->fit($max, $max);
|
||||
|
||||
$basePath = "avatars/{$userId}";
|
||||
Storage::disk('public')->makeDirectory($basePath);
|
||||
|
||||
// Save original as webp
|
||||
$originalData = (string) $img->encode('webp', $this->quality);
|
||||
Storage::disk('public')->put($basePath . '/original.webp', $originalData);
|
||||
|
||||
// Generate sizes
|
||||
foreach ($this->sizes as $name => $size) {
|
||||
$resized = $img->resize($size, $size, function ($constraint) {
|
||||
$constraint->upsize();
|
||||
})->encode('webp', $this->quality);
|
||||
Storage::disk('public')->put("{$basePath}/{$size}.webp", (string)$resized);
|
||||
}
|
||||
|
||||
$hash = sha1($originalData);
|
||||
$mime = 'image/webp';
|
||||
|
||||
// Persist metadata to user_profiles if exists, otherwise users table fallbacks
|
||||
if (SchemaHasTable('user_profiles')) {
|
||||
DB::table('user_profiles')->where('user_id', $userId)->update([
|
||||
'avatar_hash' => $hash,
|
||||
'avatar_updated_at' => Carbon::now(),
|
||||
'avatar_mime' => $mime,
|
||||
]);
|
||||
} else {
|
||||
DB::table('users')->where('id', $userId)->update([
|
||||
'avatar_hash' => $hash,
|
||||
'avatar_updated_at' => Carbon::now(),
|
||||
'avatar_mime' => $mime,
|
||||
]);
|
||||
}
|
||||
|
||||
return $hash;
|
||||
return $this->storeFromBinary($userId, $binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a legacy file path for a user (path-to-file).
|
||||
* Returns sha1 or null when missing.
|
||||
*
|
||||
* @param int $userId
|
||||
* @param string $path Absolute filesystem path
|
||||
* @return string|null
|
||||
*/
|
||||
public function storeFromLegacyFile(int $userId, string $path): ?string
|
||||
{
|
||||
$this->assertImageManagerAvailable();
|
||||
$this->assertStorageIsAllowed();
|
||||
|
||||
if (!file_exists($path) || !is_readable($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$img = Image::make($path);
|
||||
} catch (\Exception $e) {
|
||||
$binary = file_get_contents($path);
|
||||
if ($binary === false || $binary === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$max = max($img->width(), $img->height());
|
||||
$img->fit($max, $max);
|
||||
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}";
|
||||
Storage::disk('public')->makeDirectory($basePath);
|
||||
|
||||
$originalData = (string) $img->encode('webp', $this->quality);
|
||||
Storage::disk('public')->put($basePath . '/original.webp', $originalData);
|
||||
$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',
|
||||
]);
|
||||
|
||||
foreach ($this->sizes as $name => $size) {
|
||||
$resized = $img->resize($size, $size, function ($constraint) {
|
||||
$constraint->upsize();
|
||||
})->encode('webp', $this->quality);
|
||||
Storage::disk('public')->put("{$basePath}/{$size}.webp", (string)$resized);
|
||||
if ($size === 128) {
|
||||
$hashSeed = $encoded;
|
||||
}
|
||||
}
|
||||
|
||||
$hash = sha1($originalData);
|
||||
$mime = 'image/webp';
|
||||
|
||||
if (SchemaHasTable('user_profiles')) {
|
||||
DB::table('user_profiles')->where('user_id', $userId)->update([
|
||||
'avatar_hash' => $hash,
|
||||
'avatar_updated_at' => Carbon::now(),
|
||||
'avatar_mime' => $mime,
|
||||
]);
|
||||
} else {
|
||||
DB::table('users')->where('id', $userId)->update([
|
||||
'avatar_hash' => $hash,
|
||||
'avatar_updated_at' => Carbon::now(),
|
||||
'avatar_mime' => $mime,
|
||||
]);
|
||||
if ($hashSeed === '') {
|
||||
throw new RuntimeException('Avatar processing failed to generate a hash seed.');
|
||||
}
|
||||
|
||||
$hash = hash('sha256', $hashSeed);
|
||||
$this->updateProfileMetadata($userId, $hash);
|
||||
|
||||
return $hash;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: check for table existence without importing Schema facade repeatedly
|
||||
*/
|
||||
function SchemaHasTable(string $name): bool
|
||||
{
|
||||
try {
|
||||
return \Illuminate\Support\Facades\Schema::hasTable($name);
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user