Laravel Uploads

PHP MIT

Secure file upload and storage management for Laravel with Eloquent integration, private and public URLs, upload metadata tracking, and Laravel Storage support.

Stars
18
Forks
2
Downloads
2,356
Open Issues
0
Files main

Repository Files

Loading file structure...
src/Services/LaravelUploadsManager.php
<?php

namespace GhostCompiler\LaravelUploads\Services;

use GhostCompiler\LaravelUploads\Contracts\ResolvesUploadUrls;
use GhostCompiler\LaravelUploads\Exceptions\LaravelUploadsException;
use GhostCompiler\LaravelUploads\Models\Upload;
use GhostCompiler\LaravelUploads\Models\UploadLink;
use GhostCompiler\LaravelUploads\Services\Concerns\HandlesStoragePaths;
use GhostCompiler\LaravelUploads\Services\Concerns\HandlesUploadUrls;
use GhostCompiler\LaravelUploads\Services\Concerns\ValidatesUploads;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;

class LaravelUploadsManager implements ResolvesUploadUrls
{
    use HandlesStoragePaths;
    use HandlesUploadUrls;
    use ValidatesUploads;

    protected mixed $publicUrlResolver = null;

    public function upload(UploadedFile|string $pathOrFile, UploadedFile|array|string|null $file = null, array|string|null $options = []): Upload
    {
        [$path, $fileOrUrl, $options] = $this->parseUploadArguments($pathOrFile, $file, $options);
        $visibility = $this->resolveUploadVisibility($options);

        if (is_string($fileOrUrl) && $this->isValidUrl($fileOrUrl)) {
            $originalName = basename(parse_url($fileOrUrl, PHP_URL_PATH) ?: 'download');
            $extension = pathinfo($originalName, PATHINFO_EXTENSION);

            try {
                $response = \Illuminate\Support\Facades\Http::timeout(5)->head($fileOrUrl);
                $mimeType = $response->header('Content-Type');
                $size = $response->header('Content-Length');
            } catch (\Throwable) {
                $mimeType = null;
                $size = null;
            }

            return Upload::query()->create([
                'disk' => 'url',
                'visibility' => $visibility,
                'path' => $fileOrUrl,
                'original_name' => $originalName,
                'mime_type' => $mimeType,
                'extension' => $extension,
                'size' => $size ? (int) $size : null,
                'metadata' => [
                    'uploaded_at' => now()?->toIso8601String(),
                    'is_url' => true,
                ],
            ]);
        }

        $file = $fileOrUrl;
        $this->validateUploadedFile($file, $options);
        $disk = $this->disk();
        $directory = $this->directoryFor($path);
        $prepared = $this->prepareFileForStorage($file, $options);
        $path = "{$directory}/{$prepared['name']}";
        $stream = fopen($prepared['real_path'], 'rb');

        if ($stream === false) {
            throw new RuntimeException('Unable to read the prepared upload file.');
        }

        try {
            $this->storePreparedFile($disk, $path, $stream, $visibility);
        } finally {
            if (is_resource($stream)) {
                fclose($stream);
            }

            if (($prepared['temporary'] ?? false) && is_file($prepared['real_path'])) {
                @unlink($prepared['real_path']);
            }
        }

        return Upload::query()->create([
            'disk' => $disk,
            'visibility' => $visibility,
            'path' => $path,
            'original_name' => $prepared['download_name'],
            'mime_type' => $prepared['mime_type'],
            'extension' => $prepared['extension'],
            'size' => $prepared['size'],
            'metadata' => [
                'uploaded_at' => now()?->toIso8601String(),
                'original_name' => $file->getClientOriginalName(),
                'original_extension' => $file->getClientOriginalExtension(),
                'original_mime_type' => $file->getClientMimeType(),
                'original_size' => $file->getSize(),
                'compression' => $prepared['compression'],
            ],
        ]);
    }

    public function uploadMany(array $files, ?string $path = null, array|string|null $options = []): array
    {
        foreach ($files as $file) {
            if (! $file instanceof UploadedFile) {
                throw new LaravelUploadsException('LaravelUploads: Every file must be a valid uploaded file.');
            }
        }

        $uploads = [];

        foreach ($files as $file) {
            $uploads[] = $path === null
                ? $this->upload($file, $options)
                : $this->upload($path, $file, $options);
        }

        return $uploads;
    }

    public function url(?Upload $upload, ?int $expiry = null): ?string
    {
        if (! $upload) {
            return null;
        }

        return $this->resolveUrlForUploadId((int) $upload->getKey(), $expiry, $upload);
    }

    public function resolvePublicUrlsUsing(?callable $resolver): static
    {
        $this->publicUrlResolver = $resolver;

        return $this;
    }

    public function find(int|string|null $id): ?Upload
    {
        $id = $this->normalizeUploadId($id);

        if ($id === null) {
            return null;
        }

        return Upload::query()->find($id);
    }

    public function remove(Upload|int|string|null $upload): bool
    {
        $upload = $upload instanceof Upload ? $upload : $this->find($upload);

        if (! $upload) {
            return false;
        }

        $this->forgetCachedUrlsForUploadId((int) $upload->getKey());

        if ($upload->disk === 'url') {
            UploadLink::query()->where('upload_id', $upload->id)->delete();
            return (bool) $upload->delete();
        }

        $disk = Storage::disk($upload->disk);

        if (! $this->isSafeUpload($upload, $disk)) {
            return false;
        }

        if ($disk->exists($upload->path)) {
            $disk->delete($upload->path);
        }

        UploadLink::query()->where('upload_id', $upload->id)->delete();

        return (bool) $upload->delete();
    }

    public function delete(Upload|int|string|null $upload): bool
    {
        return $this->remove($upload);
    }

    public function urlFromId(int|string|null $id, ?int $expiry = null): ?string
    {
        $id = $this->normalizeUploadId($id);

        if ($id === null) {
            return null;
        }

        return $this->resolveUrlForUploadId($id, $expiry);
    }

    protected function normalizeUploadId(int|string|null $id): ?int
    {
        if ($id === null) {
            return null;
        }

        if (is_string($id)) {
            $id = trim($id);

            if ($id === '' || ! ctype_digit($id)) {
                return null;
            }
        }

        return (int) $id;
    }

    protected function parseUploadArguments(UploadedFile|string $pathOrFile, UploadedFile|array|string|null $file = null, array|string|null $options = []): array
    {
        $options = $this->normalizeUploadOptions($options);

        if ($pathOrFile instanceof UploadedFile || (is_string($pathOrFile) && $this->isValidUrl($pathOrFile))) {
            if (is_array($file)) {
                $options = $this->normalizeUploadOptions($file) + $options;
            }

            if (is_string($file)) {
                $options = $this->normalizeUploadOptions($file) + $options;
            }

            return [null, $pathOrFile, $options];
        }

        if (! $file instanceof UploadedFile && ! (is_string($file) && $this->isValidUrl($file))) {
            $message = 'LaravelUploads: A valid uploaded file or URL is required.';
            Log::error($message);

            throw new LaravelUploadsException($message);
        }

        return [$pathOrFile, $file, $options];
    }

    protected function isValidUrl(mixed $value): bool
    {
        if (! is_string($value)) {
            return false;
        }

        return filter_var($value, FILTER_VALIDATE_URL) !== false
            && preg_match('/^https?:\/\//i', $value) === 1;
    }

    protected function normalizeUploadOptions(array|string|null $options): array
    {
        if ($options === null) {
            return [];
        }

        if (is_string($options)) {
            return [
                'allow_excluded_extensions' => [$options],
            ];
        }

        if (array_is_list($options)) {
            return [
                'allow_excluded_extensions' => $options,
            ];
        }

        return $options;
    }

    protected function defaultVisibility(): string
    {
        $visibility = strtolower(trim((string) config(
            'laravel-uploads.defaults.visibility',
            config('laravel-uploads.defaults.type', 'private')
        )));

        return in_array($visibility, ['public', 'private'], true) ? $visibility : 'private';
    }

    protected function resolveUploadVisibility(array $options): string
    {
        $visibility = strtolower(trim((string) ($options['visibility'] ?? '')));

        if ($visibility === '' && isset($options['type']) && in_array(strtolower(trim((string) $options['type'])), ['public', 'private'], true)) {
            $visibility = strtolower(trim((string) $options['type']));
        }

        return in_array($visibility, ['public', 'private'], true) ? $visibility : $this->defaultVisibility();
    }

    protected function resolveUploadVariant(array $options): ?string
    {
        if ((bool) ($options['favicon'] ?? false)) {
            return 'favicon';
        }

        $variant = strtolower(trim((string) ($options['variant'] ?? $options['format'] ?? '')));

        if ($variant === '' && isset($options['type'])) {
            $type = strtolower(trim((string) $options['type']));

            if ($type === 'favicon') {
                $variant = 'favicon';
            }
        }

        return $variant !== '' ? $variant : null;
    }

    protected function generateFilename(UploadedFile $file): string
    {
        $basename = Str::uuid()->toString();
        $extension = $file->getClientOriginalExtension();

        return $extension ? "{$basename}.{$extension}" : $basename;
    }

    protected function storePreparedFile(string $disk, string $path, mixed $stream, string $visibility = 'private'): void
    {
        if (! is_resource($stream)) {
            throw new RuntimeException('Unable to read the prepared upload file.');
        }

        $stored = Storage::disk($disk)->put($path, $stream, $visibility);

        if ($stored === false) {
            throw new RuntimeException('Unable to store the prepared upload file.');
        }
    }

    protected function prepareFileForStorage(UploadedFile $file, array $options = []): array
    {
        if ($this->resolveUploadVariant($options) === 'favicon') {
            return $this->prepareFaviconForStorage($file);
        }

        if (! $this->shouldCompressImages() || ! $this->isCompressibleImage($file)) {
            return $this->prepareOriginalFileForStorage($file);
        }

        $converted = $this->convertOptimizedImage($file);

        if ($converted === null) {
            if ($this->strictImageOptimization()) {
                throw new LaravelUploadsException('LaravelUploads: Image optimization failed and strict image optimization is enabled.');
            }

            $this->validateFallbackImageForStorage($file);

            return $this->prepareOriginalFileForStorage($file, [
                'enabled' => true,
                'applied' => false,
                'fallback' => true,
            ]);
        }

        $downloadBaseName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
        $extension = $converted['extension'];

        return [
            'name' => Str::uuid()->toString().'.'.$extension,
            'download_name' => $downloadBaseName !== '' ? "{$downloadBaseName}.{$extension}" : Str::uuid()->toString().'.'.$extension,
            'real_path' => $converted['path'],
            'mime_type' => $converted['mime_type'],
            'extension' => $extension,
            'size' => filesize($converted['path']) ?: $file->getSize(),
            'temporary' => true,
            'compression' => $converted['compression'] + [
                'enabled' => true,
                'applied' => true,
                'quality' => $this->compressionQuality(),
            ],
        ];
    }

    protected function prepareOriginalFileForStorage(UploadedFile $file, array $compression = []): array
    {
        $realPath = $file->getRealPath();

        if (! is_string($realPath) || trim($realPath) === '' || ! is_file($realPath)) {
            $message = 'LaravelUploads: Unable to read the uploaded file from its temporary path.';
            Log::error($message);

            throw new LaravelUploadsException($message);
        }

        $size = filesize($realPath) ?: $file->getSize();

        return [
            'name' => $this->generateFilename($file),
            'download_name' => $file->getClientOriginalName(),
            'real_path' => $realPath,
            'mime_type' => $this->detectMimeType($file) ?: 'application/octet-stream',
            'extension' => $file->getClientOriginalExtension(),
            'size' => $size,
            'temporary' => false,
            'compression' => $compression + [
                'enabled' => false,
                'applied' => false,
            ],
        ];
    }

    protected function shouldCompressImages(): bool
    {
        return (bool) config('laravel-uploads.image_optimization.enabled', false);
    }

    protected function strictImageOptimization(): bool
    {
        return (bool) config('laravel-uploads.image_optimization.strict', false);
    }

    protected function compressionQuality(): int
    {
        return max(1, min(100, (int) config('laravel-uploads.image_optimization.quality', 75)));
    }

    protected function isCompressibleImage(UploadedFile $file): bool
    {
        $mimeType = $this->detectMimeType($file);

        return in_array($mimeType, [
            'image/jpeg',
            'image/png',
            'image/webp',
        ], true);
    }

    protected function convertOptimizedImage(UploadedFile $file): ?array
    {
        if (! (bool) config('laravel-uploads.image_optimization.convert_to_avif', true)) {
            $converted = $this->convertImageToWebp($file);

            if ($converted === null) {
                Log::warning('LaravelUploads: Image optimization skipped. WEBP conversion is unavailable. '.$this->webpSupportMessage());
            }

            return $converted;
        }

        $converted = $this->convertImageToAvif($file);

        if ($converted !== null) {
            return $converted;
        }

        $converted = $this->convertImageToWebp($file);

        if ($converted !== null) {
            Log::warning('LaravelUploads: AVIF conversion unavailable. Falling back to WEBP.');

            return $converted;
        }

        Log::warning('LaravelUploads: Image optimization skipped. AVIF and WEBP conversion are unavailable. '.$this->avifSupportMessage().' '.$this->webpSupportMessage());

        return null;
    }

    protected function validateFallbackImageForStorage(UploadedFile $file): void
    {
        $maxOutputPixels = (int) config('laravel-uploads.image_optimization.max_output_pixels', 8000000);

        if ($maxOutputPixels <= 0) {
            return;
        }

        $realPath = $file->getRealPath();

        if (! is_string($realPath) || ! is_file($realPath)) {
            return;
        }

        $dimensions = @getimagesize($realPath);

        if ($dimensions === false) {
            return;
        }

        [$width, $height] = $dimensions;

        if (($width * $height) > $maxOutputPixels) {
            throw new LaravelUploadsException('LaravelUploads: Original image exceeds the configured output pixel limit after optimization fallback.');
        }
    }

    protected function convertImageToAvif(UploadedFile $file): ?array
    {
        if (! (bool) config('laravel-uploads.image_optimization.convert_to_avif', true)) {
            return null;
        }

        $converted = $this->convertImageToAvifUsingGd($file);

        if ($converted !== null) {
            return $converted;
        }

        $converted = $this->convertImageToAvifUsingImagick($file);

        if ($converted !== null) {
            return $converted;
        }

        Log::warning('LaravelUploads: AVIF conversion is unavailable. '.$this->avifSupportMessage());

        return null;
    }

    protected function convertImageToAvifUsingGd(UploadedFile $file): ?array
    {
        if (! function_exists('imageavif')) {
            return null;
        }

        $source = $this->createImageResource($file);

        if (! $source) {
            return null;
        }

        $dimensions = $this->resizeGdImageResource($source);
        $source = $dimensions['resource'];

        $temporaryFile = tempnam(sys_get_temp_dir(), 'laravel-uploads-avif-');

        if ($temporaryFile === false) {
            imagedestroy($source);

            $message = 'LaravelUploads: Unable to create a temporary file for AVIF conversion.';
            Log::error($message);

            return null;
        }

        imagepalettetotruecolor($source);
        imagealphablending($source, true);
        imagesavealpha($source, true);

        $saved = imageavif($source, $temporaryFile, $this->compressionQuality());

        imagedestroy($source);

        if (! $saved || ! is_file($temporaryFile)) {
            @unlink($temporaryFile);

            $message = 'LaravelUploads: AVIF conversion failed while encoding the uploaded image.';
            Log::error($message);

            return null;
        }

        return [
            'path' => $temporaryFile,
            'mime_type' => 'image/avif',
            'extension' => 'avif',
            'compression' => [
                'converted_to' => 'avif',
                'driver' => 'gd',
                'resized' => $dimensions['resized'],
                'original_width' => $dimensions['original_width'],
                'original_height' => $dimensions['original_height'],
                'width' => $dimensions['width'],
                'height' => $dimensions['height'],
            ],
        ];
    }

    protected function convertImageToAvifUsingImagick(UploadedFile $file): ?array
    {
        if (! class_exists(\Imagick::class)) {
            return null;
        }

        if (! $this->imagickSupportsAvif()) {
            return null;
        }

        $temporaryFile = tempnam(sys_get_temp_dir(), 'laravel-uploads-avif-');

        if ($temporaryFile === false) {
            $message = 'LaravelUploads: Unable to create a temporary file for AVIF conversion.';
            Log::error($message);

            return null;
        }

        try {
            $imagick = new \Imagick();
            $imagick->readImage($file->getRealPath());
            $dimensions = $this->resizeImagickImage($imagick);
            $imagick->setImageFormat('AVIF');
            $imagick->setImageCompressionQuality($this->compressionQuality());
            $imagick->writeImage($temporaryFile);
            $imagick->clear();
            $imagick->destroy();
        } catch (\Throwable $exception) {
            @unlink($temporaryFile);

            $message = 'LaravelUploads: AVIF conversion failed while encoding the uploaded image with Imagick. '.$exception->getMessage();
            Log::error($message);

            return null;
        }

        if (! is_file($temporaryFile)) {
            $message = 'LaravelUploads: AVIF conversion failed while encoding the uploaded image with Imagick.';
            Log::error($message);

            return null;
        }

        return [
            'path' => $temporaryFile,
            'mime_type' => 'image/avif',
            'extension' => 'avif',
            'compression' => [
                'converted_to' => 'avif',
                'driver' => 'imagick',
                'resized' => $dimensions['resized'] ?? false,
                'original_width' => $dimensions['original_width'] ?? null,
                'original_height' => $dimensions['original_height'] ?? null,
                'width' => $dimensions['width'] ?? null,
                'height' => $dimensions['height'] ?? null,
            ],
        ];
    }

    protected function convertImageToWebp(UploadedFile $file): ?array
    {
        $converted = $this->convertImageToWebpUsingGd($file);

        if ($converted !== null) {
            return $converted;
        }

        $converted = $this->convertImageToWebpUsingImagick($file);

        if ($converted !== null) {
            return $converted;
        }

        return null;
    }

    protected function convertImageToWebpUsingGd(UploadedFile $file): ?array
    {
        if (! function_exists('imagewebp')) {
            return null;
        }

        $source = $this->createImageResource($file);

        if (! $source) {
            return null;
        }

        $dimensions = $this->resizeGdImageResource($source);
        $source = $dimensions['resource'];

        $temporaryFile = tempnam(sys_get_temp_dir(), 'laravel-uploads-webp-');

        if ($temporaryFile === false) {
            imagedestroy($source);

            $message = 'LaravelUploads: Unable to create a temporary file for WEBP conversion.';
            Log::error($message);

            return null;
        }

        imagepalettetotruecolor($source);
        imagealphablending($source, true);
        imagesavealpha($source, true);

        $saved = imagewebp($source, $temporaryFile, $this->compressionQuality());

        imagedestroy($source);

        if (! $saved || ! is_file($temporaryFile)) {
            @unlink($temporaryFile);

            $message = 'LaravelUploads: WEBP conversion failed while encoding the uploaded image.';
            Log::error($message);

            return null;
        }

        return [
            'path' => $temporaryFile,
            'mime_type' => 'image/webp',
            'extension' => 'webp',
            'compression' => [
                'converted_to' => 'webp',
                'driver' => 'gd',
                'resized' => $dimensions['resized'],
                'original_width' => $dimensions['original_width'],
                'original_height' => $dimensions['original_height'],
                'width' => $dimensions['width'],
                'height' => $dimensions['height'],
            ],
        ];
    }

    protected function convertImageToWebpUsingImagick(UploadedFile $file): ?array
    {
        if (! class_exists(\Imagick::class)) {
            return null;
        }

        if (! $this->imagickSupportsWebp()) {
            return null;
        }

        $temporaryFile = tempnam(sys_get_temp_dir(), 'laravel-uploads-webp-');

        if ($temporaryFile === false) {
            $message = 'LaravelUploads: Unable to create a temporary file for WEBP conversion.';
            Log::error($message);

            return null;
        }

        try {
            $imagick = new \Imagick();
            $imagick->readImage($file->getRealPath());
            $dimensions = $this->resizeImagickImage($imagick);
            $imagick->setImageFormat('WEBP');
            $imagick->setImageCompressionQuality($this->compressionQuality());
            $imagick->writeImage($temporaryFile);
            $imagick->clear();
            $imagick->destroy();
        } catch (\Throwable $exception) {
            @unlink($temporaryFile);

            $message = 'LaravelUploads: WEBP conversion failed while encoding the uploaded image with Imagick. '.$exception->getMessage();
            Log::error($message);

            return null;
        }

        if (! is_file($temporaryFile)) {
            $message = 'LaravelUploads: WEBP conversion failed while encoding the uploaded image with Imagick.';
            Log::error($message);

            return null;
        }

        return [
            'path' => $temporaryFile,
            'mime_type' => 'image/webp',
            'extension' => 'webp',
            'compression' => [
                'converted_to' => 'webp',
                'driver' => 'imagick',
                'resized' => $dimensions['resized'] ?? false,
                'original_width' => $dimensions['original_width'] ?? null,
                'original_height' => $dimensions['original_height'] ?? null,
                'width' => $dimensions['width'] ?? null,
                'height' => $dimensions['height'] ?? null,
            ],
        ];
    }

    protected function createImageResource(UploadedFile $file): mixed
    {
        $mimeType = $this->detectMimeType($file);
        $path = $file->getRealPath();

        return match ($mimeType) {
            'image/jpeg' => function_exists('imagecreatefromjpeg') ? @imagecreatefromjpeg($path) : null,
            'image/png' => function_exists('imagecreatefrompng') ? @imagecreatefrompng($path) : null,
            'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : null,
            default => null,
        };
    }

    protected function imageResourceFailureMessage(UploadedFile $file): string
    {
        $mimeType = $this->detectMimeType($file);

        return match ($mimeType) {
            'image/jpeg' => function_exists('imagecreatefromjpeg')
                ? 'AVIF conversion failed: could not read the JPEG image.'
                : 'PHP image support not installed: GD JPEG loader missing (imagecreatefromjpeg).',
            'image/png' => function_exists('imagecreatefrompng')
                ? 'AVIF conversion failed: could not read the PNG image.'
                : 'PHP image support not installed: GD PNG loader missing (imagecreatefrompng).',
            'image/webp' => function_exists('imagecreatefromwebp')
                ? 'AVIF conversion failed: could not read the WEBP image.'
                : 'PHP image support not installed: GD WEBP loader missing (imagecreatefromwebp).',
            default => sprintf(
                'AVIF conversion failed: unsupported source mime type [%s].',
                $mimeType ?: 'unknown'
            ),
        };
    }

    protected function imagickSupportsAvif(): bool
    {
        if (! class_exists(\Imagick::class)) {
            return false;
        }

        try {
            $formats = \Imagick::queryFormats('AVIF');
        } catch (\Throwable) {
            return false;
        }

        return $formats !== false && $formats !== [];
    }

    protected function imagickSupportsWebp(): bool
    {
        if (! class_exists(\Imagick::class)) {
            return false;
        }

        try {
            $formats = \Imagick::queryFormats('WEBP');
        } catch (\Throwable) {
            return false;
        }

        return $formats !== false && $formats !== [];
    }

    protected function resizeGdImageResource(mixed $source): array
    {
        $originalWidth = imagesx($source);
        $originalHeight = imagesy($source);
        [$targetWidth, $targetHeight] = $this->targetImageDimensions($originalWidth, $originalHeight);

        if ($targetWidth === $originalWidth && $targetHeight === $originalHeight) {
            return [
                'resource' => $source,
                'resized' => false,
                'original_width' => $originalWidth,
                'original_height' => $originalHeight,
                'width' => $originalWidth,
                'height' => $originalHeight,
            ];
        }

        if (! $this->canAllocateImagePixels($targetWidth, $targetHeight)) {
            return [
                'resource' => $source,
                'resized' => false,
                'original_width' => $originalWidth,
                'original_height' => $originalHeight,
                'width' => $originalWidth,
                'height' => $originalHeight,
            ];
        }

        $resized = imagecreatetruecolor($targetWidth, $targetHeight);

        if ($resized === false) {
            return [
                'resource' => $source,
                'resized' => false,
                'original_width' => $originalWidth,
                'original_height' => $originalHeight,
                'width' => $originalWidth,
                'height' => $originalHeight,
            ];
        }

        imagealphablending($resized, false);
        imagesavealpha($resized, true);
        $transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127);
        imagefill($resized, 0, 0, $transparent);

        imagecopyresampled(
            $resized,
            $source,
            0,
            0,
            0,
            0,
            $targetWidth,
            $targetHeight,
            $originalWidth,
            $originalHeight
        );

        imagedestroy($source);

        return [
            'resource' => $resized,
            'resized' => true,
            'original_width' => $originalWidth,
            'original_height' => $originalHeight,
            'width' => $targetWidth,
            'height' => $targetHeight,
        ];
    }

    protected function resizeImagickImage(\Imagick $imagick): array
    {
        $originalWidth = $imagick->getImageWidth();
        $originalHeight = $imagick->getImageHeight();
        [$targetWidth, $targetHeight] = $this->targetImageDimensions($originalWidth, $originalHeight);

        if ($targetWidth !== $originalWidth || $targetHeight !== $originalHeight) {
            if (! $this->canAllocateImagePixels($targetWidth, $targetHeight)) {
                return [
                    'resized' => false,
                    'original_width' => $originalWidth,
                    'original_height' => $originalHeight,
                    'width' => $originalWidth,
                    'height' => $originalHeight,
                ];
            }

            $imagick->thumbnailImage($targetWidth, $targetHeight, true, false);
        }

        return [
            'resized' => $targetWidth !== $originalWidth || $targetHeight !== $originalHeight,
            'original_width' => $originalWidth,
            'original_height' => $originalHeight,
            'width' => $targetWidth,
            'height' => $targetHeight,
        ];
    }

    protected function targetImageDimensions(int $width, int $height): array
    {
        $maxWidth = $this->maxResizeWidth();
        $maxHeight = $this->maxResizeHeight();

        if ($width <= 0 || $height <= 0) {
            return [$width, $height];
        }

        if (! $maxWidth && ! $maxHeight) {
            return [$width, $height];
        }

        $widthRatio = $maxWidth ? $maxWidth / $width : null;
        $heightRatio = $maxHeight ? $maxHeight / $height : null;

        $ratio = match (true) {
            $widthRatio !== null && $heightRatio !== null => min($widthRatio, $heightRatio, 1),
            $widthRatio !== null => min($widthRatio, 1),
            $heightRatio !== null => min($heightRatio, 1),
            default => 1,
        };

        $targetWidth = max(1, (int) round($width * $ratio));
        $targetHeight = max(1, (int) round($height * $ratio));

        return [$targetWidth, $targetHeight];
    }

    protected function canAllocateImagePixels(int $width, int $height): bool
    {
        $maxOutputPixels = (int) config('laravel-uploads.image_optimization.max_output_pixels', 8000000);

        if ($maxOutputPixels > 0 && ($width * $height) > $maxOutputPixels) {
            return false;
        }

        $memoryLimit = $this->memoryLimitInBytes();

        if ($memoryLimit === null) {
            return true;
        }

        $availableMemory = $memoryLimit - memory_get_usage(true);
        $requiredMemory = $width * $height * 8;

        return $availableMemory > 0 && $requiredMemory < ($availableMemory * 0.8);
    }

    protected function memoryLimitInBytes(): ?int
    {
        $value = trim((string) ini_get('memory_limit'));

        if ($value === '' || $value === '-1') {
            return null;
        }

        $unit = strtolower(substr($value, -1));
        $bytes = (int) $value;

        return match ($unit) {
            'g' => $bytes * 1024 * 1024 * 1024,
            'm' => $bytes * 1024 * 1024,
            'k' => $bytes * 1024,
            default => $bytes,
        };
    }

    protected function maxResizeWidth(): ?int
    {
        $value = (int) config('laravel-uploads.image_optimization.max_width');

        return $value > 0 ? $value : null;
    }

    protected function maxResizeHeight(): ?int
    {
        $value = (int) config('laravel-uploads.image_optimization.max_height');

        return $value > 0 ? $value : null;
    }

    protected function isSupportedFaviconSource(UploadedFile $file, ?string $mimeType = null, ?string $extension = null): bool
    {
        $mimeType ??= $this->detectMimeType($file);
        $extension ??= strtolower((string) $file->getClientOriginalExtension());

        return in_array($mimeType, [
            'image/jpeg',
            'image/png',
            'image/webp',
            'image/x-icon',
            'image/vnd.microsoft.icon',
        ], true) || in_array($extension, ['ico'], true);
    }

    protected function prepareFaviconForStorage(UploadedFile $file): array
    {
        if ($this->isExistingFaviconFile($file)) {
            return $this->prepareOriginalFileForStorage($file, [
                'enabled' => false,
                'applied' => false,
                'variant' => 'favicon',
                'favicon_source' => 'original',
            ]);
        }

        $source = $this->createImageResource($file);

        if (! $source) {
            throw new LaravelUploadsException('LaravelUploads: Unable to convert the uploaded image into a favicon.');
        }

        $size = max(16, (int) config('laravel-uploads.favicon.size', 64));

        if (! $this->canAllocateImagePixels($size, $size)) {
            imagedestroy($source);

            throw new LaravelUploadsException('LaravelUploads: Unable to allocate enough memory for favicon conversion.');
        }

        $sourceWidth = imagesx($source);
        $sourceHeight = imagesy($source);
        $scale = min($size / max(1, $sourceWidth), $size / max(1, $sourceHeight), 1);
        $targetWidth = max(1, (int) round($sourceWidth * $scale));
        $targetHeight = max(1, (int) round($sourceHeight * $scale));
        $targetX = (int) floor(($size - $targetWidth) / 2);
        $targetY = (int) floor(($size - $targetHeight) / 2);
        $canvas = imagecreatetruecolor($size, $size);

        if ($canvas === false) {
            imagedestroy($source);

            throw new LaravelUploadsException('LaravelUploads: Unable to allocate a favicon canvas.');
        }

        imagealphablending($canvas, false);
        imagesavealpha($canvas, true);
        $transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
        imagefill($canvas, 0, 0, $transparent);

        imagecopyresampled(
            $canvas,
            $source,
            $targetX,
            $targetY,
            0,
            0,
            $targetWidth,
            $targetHeight,
            $sourceWidth,
            $sourceHeight
        );

        imagedestroy($source);

        $temporaryFile = tempnam(sys_get_temp_dir(), 'laravel-uploads-favicon-');

        if ($temporaryFile === false) {
            imagedestroy($canvas);

            throw new LaravelUploadsException('LaravelUploads: Unable to create a temporary file for favicon conversion.');
        }

        $saved = imagepng($canvas, $temporaryFile, 9);

        imagedestroy($canvas);

        if (! $saved || ! is_file($temporaryFile)) {
            @unlink($temporaryFile);

            throw new LaravelUploadsException('LaravelUploads: Favicon conversion failed while encoding the uploaded image.');
        }

        $downloadBaseName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);

        return [
            'name' => Str::uuid()->toString().'.png',
            'download_name' => ($downloadBaseName !== '' ? $downloadBaseName : 'favicon').'.png',
            'real_path' => $temporaryFile,
            'mime_type' => 'image/png',
            'extension' => 'png',
            'size' => filesize($temporaryFile) ?: $file->getSize(),
            'temporary' => true,
            'compression' => [
                'enabled' => true,
                'applied' => true,
                'variant' => 'favicon',
                'driver' => 'gd',
                'width' => $size,
                'height' => $size,
            ],
        ];
    }

    protected function isExistingFaviconFile(UploadedFile $file): bool
    {
        $mimeType = $this->detectMimeType($file);
        $extension = strtolower((string) $file->getClientOriginalExtension());

        return in_array($mimeType, ['image/x-icon', 'image/vnd.microsoft.icon'], true)
            || $extension === 'ico';
    }

    protected function avifSupportMessage(): string
    {
        $reasons = [];

        if (! function_exists('imageavif')) {
            $reasons[] = 'Install ext-gd with AVIF support (missing imageavif).';
        }

        if (! class_exists(\Imagick::class)) {
            $reasons[] = 'Install ext-imagick for Imagick fallback support.';
        } elseif (! $this->imagickSupportsAvif()) {
            $reasons[] = 'Your Imagick/ImageMagick build does not support AVIF.';
        }

        return implode(' ', array_unique($reasons));
    }

    protected function webpSupportMessage(): string
    {
        $reasons = [];

        if (! function_exists('imagewebp')) {
            $reasons[] = 'Install ext-gd with WEBP support (missing imagewebp).';
        }

        if (! class_exists(\Imagick::class)) {
            $reasons[] = 'Install ext-imagick for Imagick fallback support.';
        } elseif (! $this->imagickSupportsWebp()) {
            $reasons[] = 'Your Imagick/ImageMagick build does not support WEBP.';
        }

        return implode(' ', array_unique($reasons));
    }

}