Upload beautify

This commit is contained in:
2026-02-17 17:14:43 +01:00
parent b053c0cc48
commit 41287914aa
106 changed files with 4948 additions and 906 deletions

View File

@@ -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.');
}
}
}