Upload beautify

This commit is contained in:
2026-02-14 15:14:12 +01:00
parent e129618910
commit 79192345e3
249 changed files with 24436 additions and 1021 deletions

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Services\Uploads;
use App\Repositories\Uploads\AuditLogRepository;
final class UploadAuditService
{
public function __construct(private readonly AuditLogRepository $repository)
{
}
public function log(?int $userId, string $action, string $ip, array $meta = []): void
{
$this->repository->log($userId, $action, $ip, $meta);
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Services\Uploads;
use App\Repositories\Uploads\UploadSessionRepository;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use RuntimeException;
final class UploadCancelService
{
public function __construct(
private readonly UploadSessionRepository $sessions,
private readonly UploadStorageService $storage,
private readonly UploadAuditService $audit
) {
}
public function cancel(string $sessionId, int $userId, string $ip): array
{
$session = $this->sessions->getOrFail($sessionId);
$lockSeconds = (int) config('uploads.chunk.lock_seconds', 10);
$lockWait = (int) config('uploads.chunk.lock_wait_seconds', 5);
$lock = Cache::lock('uploads:cancel:' . $sessionId, $lockSeconds);
try {
$lock->block($lockWait);
} catch (\Throwable $e) {
$this->audit->log($userId, 'upload_cancel_locked', $ip, [
'session_id' => $sessionId,
]);
throw new RuntimeException('Upload is busy. Please retry.');
}
try {
if (in_array($session->status, [UploadSessionStatus::CANCELLED, UploadSessionStatus::PROCESSED, UploadSessionStatus::QUARANTINED], true)) {
$this->audit->log($userId, 'upload_cancel_noop', $ip, [
'session_id' => $sessionId,
'status' => $session->status,
]);
return [
'session_id' => $sessionId,
'status' => $session->status,
];
}
$this->safeDeleteTmp($session->tempPath);
$this->sessions->updateStatus($sessionId, UploadSessionStatus::CANCELLED);
$this->sessions->updateProgress($sessionId, 0);
$this->sessions->updateFailureReason($sessionId, 'cancelled');
$this->audit->log($userId, 'upload_cancelled', $ip, [
'session_id' => $sessionId,
]);
return [
'session_id' => $sessionId,
'status' => UploadSessionStatus::CANCELLED,
];
} finally {
optional($lock)->release();
}
}
private function safeDeleteTmp(string $path): void
{
$tmpRoot = $this->storage->sectionPath('tmp');
$realRoot = realpath($tmpRoot);
$realPath = realpath($path);
if (! $realRoot || ! $realPath || strpos($realPath, $realRoot) !== 0) {
return;
}
if (File::exists($realPath)) {
File::delete($realPath);
}
}
}

View File

@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace App\Services\Uploads;
use App\DTOs\Uploads\UploadChunkResult;
use App\Repositories\Uploads\UploadSessionRepository;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use RuntimeException;
final class UploadChunkService
{
public function __construct(
private readonly UploadStorageService $storage,
private readonly UploadSessionRepository $sessions,
private readonly UploadAuditService $audit
) {
}
public function appendChunk(string $sessionId, string $chunkPath, int $offset, int $chunkSize, int $totalSize, int $userId, string $ip): UploadChunkResult
{
$session = $this->sessions->getOrFail($sessionId);
$this->ensureTmpPath($session->tempPath);
$this->ensureWritable($session->tempPath);
$this->ensureChunkReadable($chunkPath, $chunkSize);
$this->ensureLimits($totalSize, $chunkSize);
$lockSeconds = (int) config('uploads.chunk.lock_seconds', 10);
$lockWait = (int) config('uploads.chunk.lock_wait_seconds', 5);
$lock = Cache::lock('uploads:chunk:' . $sessionId, $lockSeconds);
try {
$lock->block($lockWait);
} catch (\Throwable $e) {
$this->audit->log($userId, 'upload_chunk_locked', $ip, [
'session_id' => $sessionId,
]);
throw new RuntimeException('Upload is busy. Please retry.');
}
try {
$currentSize = (int) filesize($session->tempPath);
if ($offset > $currentSize) {
$this->audit->log($userId, 'upload_chunk_offset_mismatch', $ip, [
'session_id' => $sessionId,
'offset' => $offset,
'current_size' => $currentSize,
]);
throw new RuntimeException('Invalid chunk offset.');
}
if ($offset < $currentSize) {
if ($offset + $chunkSize <= $currentSize) {
return $this->finalizeResult($sessionId, $totalSize, $currentSize);
}
$this->audit->log($userId, 'upload_chunk_overlap', $ip, [
'session_id' => $sessionId,
'offset' => $offset,
'current_size' => $currentSize,
]);
throw new RuntimeException('Chunk overlap detected.');
}
$written = $this->appendToFile($session->tempPath, $chunkPath, $offset, $chunkSize);
$newSize = $currentSize + $written;
if ($newSize > $totalSize) {
$this->audit->log($userId, 'upload_chunk_size_exceeded', $ip, [
'session_id' => $sessionId,
'new_size' => $newSize,
'total_size' => $totalSize,
]);
throw new RuntimeException('Upload exceeded expected size.');
}
$this->sessions->updateStatus($sessionId, UploadSessionStatus::TMP);
$result = $this->finalizeResult($sessionId, $totalSize, $newSize);
$this->audit->log($userId, 'upload_chunk_appended', $ip, [
'session_id' => $sessionId,
'received_bytes' => $newSize,
'total_size' => $totalSize,
'progress' => $result->progress,
]);
return $result;
} finally {
optional($lock)->release();
}
}
private function finalizeResult(string $sessionId, int $totalSize, int $currentSize): UploadChunkResult
{
$progress = $totalSize > 0 ? (int) floor(($currentSize / $totalSize) * 100) : 0;
$progress = min(90, max(0, $progress));
$this->sessions->updateProgress($sessionId, $progress);
return new UploadChunkResult(
$sessionId,
UploadSessionStatus::TMP,
$currentSize,
$totalSize,
$progress
);
}
private function ensureTmpPath(string $path): void
{
$tmpRoot = $this->storage->sectionPath('tmp');
$realRoot = realpath($tmpRoot);
$realPath = realpath($path);
if (! $realRoot || ! $realPath || strpos($realPath, $realRoot) !== 0) {
throw new RuntimeException('Invalid temp path.');
}
}
private function ensureWritable(string $path): void
{
if (! File::exists($path)) {
File::put($path, '');
}
if (! is_writable($path)) {
throw new RuntimeException('Upload path not writable.');
}
}
private function ensureLimits(int $totalSize, int $chunkSize): void
{
$maxBytes = (int) config('uploads.max_size_mb', 0) * 1024 * 1024;
if ($maxBytes > 0 && $totalSize > $maxBytes) {
throw new RuntimeException('Upload exceeds max size.');
}
$maxChunk = (int) config('uploads.chunk.max_bytes', 0);
if ($maxChunk > 0 && $chunkSize > $maxChunk) {
throw new RuntimeException('Chunk exceeds max size.');
}
}
private function ensureChunkReadable(string $chunkPath, int $chunkSize): void
{
$exists = is_file($chunkPath);
$readable = $exists ? is_readable($chunkPath) : false;
$actualSize = $exists ? (int) @filesize($chunkPath) : null;
if (! $exists || ! $readable) {
logger()->warning('Upload chunk unreadable or missing', [
'chunk_path' => $chunkPath,
'expected_size' => $chunkSize,
'exists' => $exists,
'readable' => $readable,
'actual_size' => $actualSize,
]);
throw new RuntimeException('Upload chunk missing.');
}
if ($actualSize !== $chunkSize) {
logger()->warning('Upload chunk size mismatch', [
'chunk_path' => $chunkPath,
'expected_size' => $chunkSize,
'actual_size' => $actualSize,
]);
throw new RuntimeException('Chunk size mismatch.');
}
}
private function appendToFile(string $targetPath, string $chunkPath, int $offset, int $chunkSize): int
{
$in = fopen($chunkPath, 'rb');
if (! $in) {
throw new RuntimeException('Unable to read upload chunk.');
}
$out = fopen($targetPath, 'c+b');
if (! $out) {
fclose($in);
throw new RuntimeException('Unable to write upload chunk.');
}
if (fseek($out, $offset) !== 0) {
fclose($in);
fclose($out);
throw new RuntimeException('Failed to seek in upload file.');
}
$written = stream_copy_to_stream($in, $out, $chunkSize);
fflush($out);
fclose($in);
fclose($out);
if ($written === false || (int) $written !== $chunkSize) {
throw new RuntimeException('Incomplete chunk write.');
}
return (int) $written;
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Services\Uploads;
use Illuminate\Support\Facades\File;
use Intervention\Image\ImageManager as ImageManager;
use Intervention\Image\Interfaces\ImageInterface as InterventionImageInterface;
use RuntimeException;
final class UploadDerivativesService
{
private bool $imageAvailable = false;
private ?ImageManager $manager = null;
public function __construct(private readonly UploadStorageService $storage)
{
// Intervention Image v3 uses ImageManager; instantiate appropriate driver
try {
$this->manager = extension_loaded('gd') ? ImageManager::gd() : ImageManager::imagick();
$this->imageAvailable = true;
} catch (\Throwable $e) {
logger()->warning('Intervention Image present but configuration failed: ' . $e->getMessage());
$this->imageAvailable = false;
$this->manager = null;
}
}
public function storeOriginal(string $sourcePath, string $hash): string
{
$this->assertImageAvailable();
$dir = $this->storage->ensureHashDirectory('originals', $hash);
$target = $dir . DIRECTORY_SEPARATOR . 'orig.webp';
$quality = (int) config('uploads.quality', 85);
/** @var InterventionImageInterface $img */
$img = $this->manager->read($sourcePath);
$encoder = new \Intervention\Image\Encoders\WebpEncoder($quality);
$encoded = (string) $img->encode($encoder);
File::put($target, $encoded);
return $target;
}
public function generatePublicDerivatives(string $sourcePath, string $hash): array
{
$this->assertImageAvailable();
$quality = (int) config('uploads.quality', 85);
$variants = (array) config('uploads.derivatives', []);
$dir = $this->storage->publicHashDirectory($hash);
$written = [];
foreach ($variants as $variant => $options) {
$variant = (string) $variant;
$path = $dir . DIRECTORY_SEPARATOR . $variant . '.webp';
/** @var InterventionImageInterface $img */
$img = $this->manager->read($sourcePath);
if (isset($options['size'])) {
$size = (int) $options['size'];
$out = $img->cover($size, $size);
} else {
$max = (int) ($options['max'] ?? 0);
if ($max <= 0) {
$max = 2560;
}
$out = $img->scaleDown($max, $max);
}
$encoder = new \Intervention\Image\Encoders\WebpEncoder($quality);
$encoded = (string) $out->encode($encoder);
File::put($path, $encoded);
$written[$variant] = $path;
}
return $written;
}
private function assertImageAvailable(): void
{
if (! $this->imageAvailable) {
throw new RuntimeException('Intervention Image is not available.');
}
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Services\Uploads;
use RuntimeException;
final class UploadHashService
{
public function hashFile(string $path): string
{
$hash = hash_file('sha256', $path);
if ($hash === false) {
throw new RuntimeException('Failed to hash upload file.');
}
return $hash;
}
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Services\Uploads;
use App\DTOs\Uploads\UploadSessionData;
use App\DTOs\Uploads\UploadInitResult;
use App\DTOs\Uploads\UploadValidatedFile;
use App\DTOs\Uploads\UploadScanResult;
use App\Repositories\Uploads\ArtworkFileRepository;
use App\Repositories\Uploads\UploadSessionRepository;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\File;
final class UploadPipelineService
{
public function __construct(
private readonly UploadStorageService $storage,
private readonly UploadSessionRepository $sessions,
private readonly UploadValidationService $validator,
private readonly UploadHashService $hasher,
private readonly UploadScanService $scanner,
private readonly UploadAuditService $audit,
private readonly UploadDerivativesService $derivatives,
private readonly ArtworkFileRepository $artworkFiles,
private readonly UploadTokenService $tokens
) {
}
public function initSession(int $userId, string $ip): UploadInitResult
{
$dir = $this->storage->ensureSection('tmp');
$filename = Str::uuid()->toString() . '.upload';
$tempPath = $dir . DIRECTORY_SEPARATOR . $filename;
File::put($tempPath, '');
$sessionId = (string) Str::uuid();
$session = $this->sessions->create($sessionId, $userId, $tempPath, UploadSessionStatus::INIT, $ip);
$token = $this->tokens->generate($sessionId, $userId);
$this->audit->log($userId, 'upload_init', $ip, [
'session_id' => $sessionId,
]);
return new UploadInitResult($session->id, $token, $session->status);
}
public function receiveToTmp(UploadedFile $file, int $userId, string $ip): UploadSessionData
{
$stored = $this->storage->storeUploadedFile($file, 'tmp');
$sessionId = (string) Str::uuid();
$session = $this->sessions->create($sessionId, $userId, $stored->path, UploadSessionStatus::TMP, $ip);
$this->sessions->updateProgress($sessionId, 10);
$this->audit->log($userId, 'upload_received', $ip, [
'session_id' => $sessionId,
'size' => $stored->size,
]);
return $session;
}
public function validateAndHash(string $sessionId): UploadValidatedFile
{
$session = $this->sessions->getOrFail($sessionId);
$validation = $this->validator->validate($session->tempPath);
if (! $validation->ok) {
$this->quarantine($session, $validation->reason);
return new UploadValidatedFile($validation, null);
}
$hash = $this->hasher->hashFile($session->tempPath);
$this->sessions->updateStatus($sessionId, UploadSessionStatus::VALIDATED);
$this->sessions->updateProgress($sessionId, 30);
$this->audit->log($session->userId, 'upload_validated', $session->ip, [
'session_id' => $sessionId,
'hash' => $hash,
]);
return new UploadValidatedFile($validation, $hash);
}
public function scan(string $sessionId): UploadScanResult
{
$session = $this->sessions->getOrFail($sessionId);
$result = $this->scanner->scan($session->tempPath);
if (! $result->ok) {
$this->quarantine($session, $result->reason);
return $result;
}
$this->sessions->updateStatus($sessionId, UploadSessionStatus::SCANNED);
$this->sessions->updateProgress($sessionId, 50);
$this->audit->log($session->userId, 'upload_scanned', $session->ip, [
'session_id' => $sessionId,
]);
return $result;
}
public function processAndPublish(string $sessionId, string $hash, int $artworkId): array
{
$session = $this->sessions->getOrFail($sessionId);
$originalPath = $this->derivatives->storeOriginal($session->tempPath, $hash);
$originalRelative = $this->storage->sectionRelativePath('originals', $hash, 'orig.webp');
$this->artworkFiles->upsert($artworkId, 'orig', $originalRelative, 'image/webp', (int) filesize($originalPath));
$publicAbsolute = $this->derivatives->generatePublicDerivatives($session->tempPath, $hash);
$publicRelative = [];
foreach ($publicAbsolute as $variant => $absolutePath) {
$filename = $variant . '.webp';
$relativePath = $this->storage->publicRelativePath($hash, $filename);
$this->artworkFiles->upsert($artworkId, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath));
$publicRelative[$variant] = $relativePath;
}
$this->sessions->updateStatus($sessionId, UploadSessionStatus::PROCESSED);
$this->sessions->updateProgress($sessionId, 100);
$this->audit->log($session->userId, 'upload_processed', $session->ip, [
'session_id' => $sessionId,
'hash' => $hash,
'artwork_id' => $artworkId,
]);
return [
'orig' => $originalRelative,
'public' => $publicRelative,
];
}
private function quarantine(UploadSessionData $session, string $reason): void
{
$newPath = $this->storage->moveToSection($session->tempPath, 'quarantine');
$this->sessions->updateTempPath($session->id, $newPath);
$this->sessions->updateStatus($session->id, UploadSessionStatus::QUARANTINED);
$this->sessions->updateFailureReason($session->id, $reason);
$this->sessions->updateProgress($session->id, 0);
$this->audit->log($session->userId, 'upload_quarantined', $session->ip, [
'session_id' => $session->id,
'reason' => $reason,
]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Services\Uploads;
use App\Repositories\Uploads\UploadSessionRepository;
use Carbon\CarbonImmutable;
use RuntimeException;
final class UploadQuotaService
{
public function __construct(private readonly UploadSessionRepository $sessions)
{
}
public function enforce(int $userId): void
{
$activeLimit = (int) config('uploads.quotas.max_active_sessions', 0);
if ($activeLimit > 0) {
$active = $this->sessions->countActiveForUser($userId);
if ($active >= $activeLimit) {
throw new RuntimeException('Upload limit reached.');
}
}
$dailyLimit = (int) config('uploads.quotas.max_daily_sessions', 0);
if ($dailyLimit > 0) {
$since = CarbonImmutable::now()->startOfDay();
$daily = $this->sessions->countForUserSince($userId, $since);
if ($daily >= $dailyLimit) {
throw new RuntimeException('Daily upload limit reached.');
}
}
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Services\Uploads;
use App\DTOs\Uploads\UploadScanResult;
use RuntimeException;
use Symfony\Component\Process\Process;
final class UploadScanService
{
public function scan(string $path): UploadScanResult
{
if (! (bool) config('uploads.scan.enabled', false)) {
return UploadScanResult::clean();
}
$command = config('uploads.scan.command', []);
if (! is_array($command) || $command === []) {
throw new RuntimeException('Upload scan enabled but no command configured.');
}
$command = $this->buildCommand($command, $path);
$process = new Process($command);
$process->run();
if ($process->isSuccessful()) {
return UploadScanResult::clean();
}
if ($process->getExitCode() === 1) {
return UploadScanResult::infected(trim($process->getOutput()));
}
throw new RuntimeException('Upload scan failed: ' . trim($process->getErrorOutput()));
}
private function buildCommand(array $command, string $path): array
{
return array_map(static function (string $part) use ($path): string {
return $part === '{path}' ? $path : $part;
}, $command);
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Services\Uploads;
final class UploadSessionStatus
{
public const INIT = 'init';
public const TMP = 'tmp';
public const VALIDATED = 'validated';
public const SCANNED = 'scanned';
public const PROCESSED = 'processed';
public const QUARANTINED = 'quarantined';
public const CANCELLED = 'cancelled';
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Services\Uploads;
use App\Repositories\Uploads\UploadSessionRepository;
use Illuminate\Support\Facades\File;
final class UploadStatusService
{
public function __construct(
private readonly UploadSessionRepository $sessions,
private readonly UploadStorageService $storage
)
{
}
public function get(string $sessionId): array
{
$session = $this->sessions->getOrFail($sessionId);
$receivedBytes = $this->safeFileSize($session->tempPath);
return [
'session_id' => $session->id,
'status' => $session->status,
'progress' => $session->progress,
'failure_reason' => $session->failureReason,
'user_id' => $session->userId,
'received_bytes' => $receivedBytes,
];
}
private function safeFileSize(string $path): int
{
$tmpRoot = $this->storage->sectionPath('tmp');
$realRoot = realpath($tmpRoot);
$realPath = realpath($path);
if (! $realRoot || ! $realPath || strpos($realPath, $realRoot) !== 0) {
return 0;
}
if (! File::exists($realPath)) {
return 0;
}
return (int) File::size($realPath);
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Services\Uploads;
use App\DTOs\Uploads\UploadStoredFile;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use RuntimeException;
final class UploadStorageService
{
public function sectionPath(string $section): string
{
$root = rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR);
$paths = (array) config('uploads.paths');
if (! array_key_exists($section, $paths)) {
throw new RuntimeException('Unknown upload storage section: ' . $section);
}
return $root . DIRECTORY_SEPARATOR . trim((string) $paths[$section], DIRECTORY_SEPARATOR);
}
public function ensureSection(string $section): string
{
$path = $this->sectionPath($section);
if (! File::exists($path)) {
File::makeDirectory($path, 0755, true);
}
return $path;
}
public function storeUploadedFile(UploadedFile $file, string $section): UploadStoredFile
{
$dir = $this->ensureSection($section);
$extension = $this->safeExtension($file);
$filename = Str::uuid()->toString() . ($extension !== '' ? '.' . $extension : '');
$file->move($dir, $filename);
$path = $dir . DIRECTORY_SEPARATOR . $filename;
return UploadStoredFile::fromPath($path);
}
public function moveToSection(string $path, string $section): string
{
if (! is_file($path)) {
throw new RuntimeException('Source file not found for move.');
}
$dir = $this->ensureSection($section);
$extension = (string) pathinfo($path, PATHINFO_EXTENSION);
$filename = Str::uuid()->toString() . ($extension !== '' ? '.' . $extension : '');
$target = $dir . DIRECTORY_SEPARATOR . $filename;
File::move($path, $target);
return $target;
}
public function ensureHashDirectory(string $section, string $hash): string
{
$segments = $this->hashSegments($hash);
$dir = $this->sectionPath($section) . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments);
if (! File::exists($dir)) {
File::makeDirectory($dir, 0755, true);
}
return $dir;
}
public function publicHashDirectory(string $hash): string
{
$prefix = trim((string) config('uploads.public_img_prefix', 'img'), DIRECTORY_SEPARATOR);
$base = $this->sectionPath('public') . DIRECTORY_SEPARATOR . $prefix;
if (! File::exists($base)) {
File::makeDirectory($base, 0755, true);
}
$segments = $this->hashSegments($hash);
$dir = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments);
if (! File::exists($dir)) {
File::makeDirectory($dir, 0755, true);
}
return $dir;
}
public function publicRelativePath(string $hash, string $filename): string
{
$prefix = trim((string) config('uploads.public_img_prefix', 'img'), DIRECTORY_SEPARATOR);
$segments = $this->hashSegments($hash);
return $prefix . '/' . implode('/', $segments) . '/' . ltrim($filename, '/');
}
public function sectionRelativePath(string $section, string $hash, string $filename): string
{
$segments = $this->hashSegments($hash);
$section = trim($section, DIRECTORY_SEPARATOR);
return $section . '/' . implode('/', $segments) . '/' . ltrim($filename, '/');
}
private function safeExtension(UploadedFile $file): string
{
$extension = (string) $file->guessExtension();
$extension = strtolower($extension);
return preg_match('/^[a-z0-9]+$/', $extension) ? $extension : '';
}
private function hashSegments(string $hash): array
{
$hash = strtolower($hash);
$hash = preg_replace('/[^a-z0-9]/', '', $hash) ?? '';
$hash = str_pad($hash, 6, '0');
$segments = [
substr($hash, 0, 2),
substr($hash, 2, 2),
substr($hash, 4, 2),
];
return array_map(static fn (string $part): string => $part === '' ? '00' : $part, $segments);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Services\Uploads;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
final class UploadTokenService
{
public function generate(string $sessionId, int $userId): string
{
$token = Str::random(64);
$ttl = (int) config('uploads.tokens.ttl_minutes', 60);
Cache::put($this->cacheKey($token), [
'session_id' => $sessionId,
'user_id' => $userId,
], now()->addMinutes($ttl));
return $token;
}
public function get(string $token): ?array
{
$data = Cache::get($this->cacheKey($token));
return is_array($data) ? $data : null;
}
private function cacheKey(string $token): string
{
return 'uploads:token:' . $token;
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Services\Uploads;
use App\DTOs\Uploads\UploadValidationResult;
final class UploadValidationService
{
public function validate(string $path): UploadValidationResult
{
if (! is_file($path) || ! is_readable($path)) {
return UploadValidationResult::fail('file_unreadable');
}
$size = (int) filesize($path);
$maxBytes = $this->maxSizeBytes();
if ($maxBytes > 0 && $size > $maxBytes) {
return UploadValidationResult::fail('file_too_large', null, null, null, $size);
}
$mime = $this->detectMime($path);
if ($mime === '' || ! in_array($mime, $this->allowedMimes(), true)) {
return UploadValidationResult::fail('mime_not_allowed', null, null, $mime, $size);
}
$info = @getimagesize($path);
if (! $info || empty($info[0]) || empty($info[1])) {
return UploadValidationResult::fail('invalid_image', null, null, $mime, $size);
}
$width = (int) $info[0];
$height = (int) $info[1];
$maxPixels = $this->maxPixels();
if ($maxPixels > 0 && ($width > $maxPixels || $height > $maxPixels)) {
return UploadValidationResult::fail('image_too_large', $width, $height, $mime, $size);
}
$data = @file_get_contents($path);
if ($data === false) {
return UploadValidationResult::fail('file_unreadable', $width, $height, $mime, $size);
}
$image = @imagecreatefromstring($data);
if ($image === false) {
return UploadValidationResult::fail('decode_failed', $width, $height, $mime, $size);
}
$reencodeOk = $this->reencodeTest($image, $mime);
imagedestroy($image);
if (! $reencodeOk) {
return UploadValidationResult::fail('reencode_failed', $width, $height, $mime, $size);
}
return UploadValidationResult::ok($width, $height, $mime, $size);
}
private function maxSizeBytes(): int
{
return (int) config('uploads.max_size_mb', 0) * 1024 * 1024;
}
private function maxPixels(): int
{
return (int) config('uploads.max_pixels', 0);
}
private function allowedMimes(): array
{
$allowed = (array) config('uploads.allowed_mimes', []);
if ((bool) config('uploads.allow_gif', false)) {
$allowed[] = 'image/gif';
}
return array_values(array_unique($allowed));
}
private function detectMime(string $path): string
{
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($path);
return $mime ? (string) $mime : '';
}
private function reencodeTest($image, string $mime): bool
{
ob_start();
$result = false;
switch ($mime) {
case 'image/jpeg':
$result = function_exists('imagejpeg') ? imagejpeg($image, null, 80) : false;
break;
case 'image/png':
$result = function_exists('imagepng') ? imagepng($image, null, 6) : false;
break;
case 'image/webp':
$result = function_exists('imagewebp') ? imagewebp($image, null, 80) : false;
break;
case 'image/gif':
$result = function_exists('imagegif') ? imagegif($image) : false;
break;
}
$data = ob_get_clean();
return (bool) $result && is_string($data) && $data !== '';
}
}