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/Concerns/HandlesUploadUrls.php
<?php

namespace GhostCompiler\LaravelUploads\Services\Concerns;

use GhostCompiler\LaravelUploads\Models\Upload;
use GhostCompiler\LaravelUploads\Models\UploadLink;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

trait HandlesUploadUrls
{
    protected function resolveUrlForUploadId(int $uploadId, ?int $expiry = null, ?Upload $upload = null): ?string
    {
        $upload ??= $this->find($uploadId);

        if (! $upload) {
            return null;
        }

        if ($upload->visibility === 'public' && $upload->disk !== 'url') {
            return $this->publicUrlForUpload($upload);
        }

        $minutes = $this->resolveLinkExpiryMinutes($expiry);
        $cacheEnabled = $this->shouldCacheGeneratedUrls() && $minutes > 0;
        $cacheKey = $this->uploadUrlCacheKey($uploadId, $minutes);

        if ($cacheEnabled) {
            $cachedUrl = Cache::get($cacheKey);

            if (is_string($cachedUrl) && $cachedUrl !== '') {
                if ($this->cachedPrivateUrlIsUsable($cachedUrl, $uploadId)) {
                    return $cachedUrl;
                }

                Cache::forget($cacheKey);
            }
        }

        $link = $this->createLink($upload, $minutes);
        $url = $link->url();

        if ($cacheEnabled && $link->expires_at) {
            Cache::put($cacheKey, $url, $link->expires_at);
            $this->rememberUploadUrlCacheKey($uploadId, $cacheKey, $link->expires_at);
        }

        return $url;
    }

    protected function publicUrlForUpload(Upload $upload): ?string
    {
        $disk = Storage::disk($upload->disk);

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

        $resolvedUrl = $this->resolvePublicUrlWithCustomResolver($upload, $disk);

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

        try {
            return $disk->url($upload->path);
        } catch (\Throwable) {
            return null;
        }
    }

    protected function resolvePublicUrlWithCustomResolver(Upload $upload, FilesystemAdapter $disk): ?string
    {
        $resolver = $this->publicUrlResolver ?? config('laravel-uploads.urls.public_resolver');

        if (! $resolver) {
            return null;
        }

        if (is_string($resolver) && class_exists($resolver)) {
            $resolver = app($resolver);
        }

        if (is_object($resolver) && method_exists($resolver, 'publicUrl')) {
            $resolver = [$resolver, 'publicUrl'];
        }

        if (! is_callable($resolver)) {
            return null;
        }

        try {
            $url = $resolver($upload, $disk, $upload->path);
        } catch (\Throwable $exception) {
            Log::warning('LaravelUploads: Public URL resolver failed. '.$exception->getMessage());

            return null;
        }

        $url = is_string($url) ? trim($url) : '';

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

    protected function createLink(Upload $upload, ?int $expiry = null): UploadLink
    {
        $minutes = $this->resolveLinkExpiryMinutes($expiry);
        $expiresAt = ($upload->disk === 'url' && $upload->visibility === 'public')
            ? null
            : now()->addMinutes($minutes);

        return UploadLink::query()->create([
            'upload_id' => $upload->id,
            'token' => Str::random(64),
            'expires_at' => $expiresAt,
        ]);
    }

    protected function resolveLinkExpiryMinutes(?int $expiry = null): int
    {
        $minutes = $expiry ?? (int) config('laravel-uploads.defaults.expiry', 60);

        return max(0, $minutes);
    }

    protected function shouldCacheGeneratedUrls(): bool
    {
        return (bool) config('laravel-uploads.cache.enabled', true);
    }

    protected function uploadUrlCacheKey(int $uploadId, int $minutes): string
    {
        return "laravel-uploads:url:{$uploadId}:{$minutes}";
    }

    protected function cachedPrivateUrlIsUsable(string $url, int $uploadId): bool
    {
        $path = parse_url($url, PHP_URL_PATH);

        if (! is_string($path) || $path === '') {
            return false;
        }

        $token = basename($path);

        if (! is_string($token) || ! preg_match('/^[A-Za-z0-9]{64}$/', $token)) {
            return false;
        }

        return UploadLink::query()
            ->where('upload_id', $uploadId)
            ->where('token', $token)
            ->where(function ($query): void {
                $query->whereNull('expires_at')
                    ->orWhere('expires_at', '>', now());
            })
            ->exists();
    }

    protected function uploadUrlCacheRegistryKey(int $uploadId): string
    {
        return "laravel-uploads:url-keys:{$uploadId}";
    }

    protected function rememberUploadUrlCacheKey(int $uploadId, string $cacheKey, mixed $expiresAt): void
    {
        $registryKey = $this->uploadUrlCacheRegistryKey($uploadId);
        $cacheKeys = Cache::get($registryKey, []);

        if (! is_array($cacheKeys)) {
            $cacheKeys = [];
        }

        if (! in_array($cacheKey, $cacheKeys, true)) {
            $cacheKeys[] = $cacheKey;
        }

        Cache::put($registryKey, $cacheKeys, $this->uploadUrlCacheRegistryExpiresAt($expiresAt));
    }

    protected function uploadUrlCacheRegistryExpiresAt(mixed $expiresAt): \DateTimeInterface
    {
        $registryTtl = max(1, (int) config('laravel-uploads.cache.registry_ttl', 60));
        $registryExpiresAt = now()->addMinutes($registryTtl);

        if ($expiresAt instanceof \DateTimeInterface && $expiresAt > $registryExpiresAt) {
            return $expiresAt;
        }

        return $registryExpiresAt;
    }

    protected function forgetCachedUrlsForUploadId(int $uploadId): void
    {
        if (! $this->shouldCacheGeneratedUrls()) {
            return;
        }

        $registryKey = $this->uploadUrlCacheRegistryKey($uploadId);
        $cacheKeys = Cache::pull($registryKey, []);

        if (! is_array($cacheKeys)) {
            return;
        }

        foreach ($cacheKeys as $cacheKey) {
            if (is_string($cacheKey) && $cacheKey !== '') {
                Cache::forget($cacheKey);
            }
        }
    }
}