Save workspace changes
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Uploads;
|
||||
|
||||
use App\DTOs\Uploads\UploadValidationResult;
|
||||
|
||||
final class UploadArchiveValidationService
|
||||
{
|
||||
public function validate(string $path): UploadValidationResult
|
||||
{
|
||||
if (! is_file($path) || ! is_readable($path)) {
|
||||
return UploadValidationResult::fail('file_unreadable');
|
||||
}
|
||||
|
||||
$size = (int) filesize($path);
|
||||
$maxBytes = (int) config('uploads.max_archive_size_mb', 0) * 1024 * 1024;
|
||||
if ($maxBytes > 0 && $size > $maxBytes) {
|
||||
return UploadValidationResult::fail('file_too_large', null, null, null, $size);
|
||||
}
|
||||
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = (string) ($finfo->file($path) ?: '');
|
||||
$allowed = array_values(array_unique((array) config('uploads.allowed_archive_mimes', [])));
|
||||
|
||||
if ($mime === '' || ! in_array($mime, $allowed, true)) {
|
||||
return UploadValidationResult::fail('mime_not_allowed', null, null, $mime, $size);
|
||||
}
|
||||
|
||||
return UploadValidationResult::ok(0, 0, $mime, $size);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Uploads;
|
||||
|
||||
use App\Services\Images\SquareThumbnailService;
|
||||
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,
|
||||
private readonly SquareThumbnailService $squareThumbnails,
|
||||
)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{local_path: string, object_path: string, filename: string, mime: string, size: int, ext: string}
|
||||
*/
|
||||
public function storeOriginal(string $sourcePath, string $hash, ?string $originalFileName = null): array
|
||||
{
|
||||
$origExt = $this->resolveOriginalExtension($sourcePath, $originalFileName);
|
||||
$filename = $hash . ($origExt !== '' ? '.' . $origExt : '');
|
||||
$target = $this->storage->localOriginalPath($hash, $filename);
|
||||
|
||||
if (! File::copy($sourcePath, $target)) {
|
||||
throw new RuntimeException('Unable to copy original file into local artwork originals storage.');
|
||||
}
|
||||
|
||||
$mime = (string) (File::mimeType($target) ?: 'application/octet-stream');
|
||||
$size = (int) filesize($target);
|
||||
$objectPath = $this->storage->objectPathForVariant('original', $hash, $filename);
|
||||
$this->storage->putObjectFromPath($target, $objectPath, $mime);
|
||||
|
||||
return [
|
||||
'local_path' => $target,
|
||||
'object_path' => $objectPath,
|
||||
'filename' => $filename,
|
||||
'mime' => $mime,
|
||||
'size' => $size,
|
||||
'ext' => $origExt,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveOriginalExtension(string $sourcePath, ?string $originalFileName): string
|
||||
{
|
||||
$fromClientName = strtolower((string) pathinfo((string) $originalFileName, PATHINFO_EXTENSION));
|
||||
if ($fromClientName !== '' && preg_match('/^[a-z0-9]{1,12}$/', $fromClientName) === 1) {
|
||||
return $fromClientName;
|
||||
}
|
||||
|
||||
$fromSource = strtolower((string) pathinfo($sourcePath, PATHINFO_EXTENSION));
|
||||
if ($fromSource !== '' && $fromSource !== 'upload' && preg_match('/^[a-z0-9]{1,12}$/', $fromSource) === 1) {
|
||||
return $fromSource;
|
||||
}
|
||||
|
||||
$mime = File::exists($sourcePath) ? (string) (File::mimeType($sourcePath) ?? '') : '';
|
||||
return $this->extensionFromMime($mime);
|
||||
}
|
||||
|
||||
private function extensionFromMime(string $mime): string
|
||||
{
|
||||
return match (strtolower($mime)) {
|
||||
'image/jpeg', 'image/jpg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
'image/gif' => 'gif',
|
||||
'image/bmp' => 'bmp',
|
||||
'image/tiff' => 'tif',
|
||||
'application/zip', 'application/x-zip-compressed' => 'zip',
|
||||
'application/x-rar-compressed', 'application/vnd.rar' => 'rar',
|
||||
'application/x-7z-compressed' => '7z',
|
||||
'application/x-tar' => 'tar',
|
||||
'application/gzip', 'application/x-gzip' => 'gz',
|
||||
default => 'bin',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{path: string, size: int, mime: string}>
|
||||
*/
|
||||
public function generatePublicDerivatives(string $sourcePath, string $hash): array
|
||||
{
|
||||
$this->assertImageAvailable();
|
||||
$quality = (int) config('uploads.quality', 85);
|
||||
$variants = (array) config('uploads.derivatives', []);
|
||||
$written = [];
|
||||
|
||||
foreach ($variants as $variant => $options) {
|
||||
$variant = (string) $variant;
|
||||
|
||||
if ($variant === 'sq') {
|
||||
$written[$variant] = $this->generateSquareDerivative($sourcePath, $hash);
|
||||
continue;
|
||||
}
|
||||
|
||||
$filename = $hash . '.webp';
|
||||
$objectPath = $this->storage->objectPathForVariant($variant, $hash, $filename);
|
||||
|
||||
/** @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);
|
||||
$this->storage->putObjectContents($objectPath, $encoded, 'image/webp');
|
||||
$written[$variant] = [
|
||||
'path' => $objectPath,
|
||||
'size' => strlen($encoded),
|
||||
'mime' => 'image/webp',
|
||||
];
|
||||
}
|
||||
|
||||
return $written;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
* @return array{path: string, size: int, mime: string, result: SquareThumbnailResultData}
|
||||
*/
|
||||
public function generateSquareDerivative(string $sourcePath, string $hash, array $options = []): array
|
||||
{
|
||||
$filename = $hash . '.webp';
|
||||
$objectPath = $this->storage->objectPathForVariant('sq', $hash, $filename);
|
||||
$temporaryPath = tempnam(sys_get_temp_dir(), 'sq-derivative-');
|
||||
|
||||
if ($temporaryPath === false) {
|
||||
throw new RuntimeException('Unable to allocate a temporary file for square derivative generation.');
|
||||
}
|
||||
|
||||
$temporaryWebp = $temporaryPath . '.webp';
|
||||
rename($temporaryPath, $temporaryWebp);
|
||||
|
||||
try {
|
||||
$result = $this->squareThumbnails->generateFromPath($sourcePath, $temporaryWebp, $options);
|
||||
$mime = 'image/webp';
|
||||
$size = (int) filesize($temporaryWebp);
|
||||
$this->storage->putObjectFromPath($temporaryWebp, $objectPath, $mime);
|
||||
|
||||
return [
|
||||
'path' => $objectPath,
|
||||
'size' => $size,
|
||||
'mime' => $mime,
|
||||
'result' => $result,
|
||||
];
|
||||
} finally {
|
||||
File::delete($temporaryWebp);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertImageAvailable(): void
|
||||
{
|
||||
if (! $this->imageAvailable) {
|
||||
throw new RuntimeException('Intervention Image is not available.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
<?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\Models\Artwork;
|
||||
use App\Repositories\Uploads\ArtworkFileRepository;
|
||||
use App\Repositories\Uploads\UploadSessionRepository;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class UploadPipelineService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UploadStorageService $storage,
|
||||
private readonly UploadSessionRepository $sessions,
|
||||
private readonly UploadValidationService $validator,
|
||||
private readonly UploadArchiveValidationService $archiveValidator,
|
||||
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 validateAndHashArchive(string $sessionId): UploadValidatedFile
|
||||
{
|
||||
$session = $this->sessions->getOrFail($sessionId);
|
||||
$validation = $this->archiveValidator->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_archive_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,
|
||||
?string $originalFileName = null,
|
||||
?string $archiveSessionId = null,
|
||||
?string $archiveHash = null,
|
||||
?string $archiveOriginalFileName = null,
|
||||
array $additionalScreenshotSessions = []
|
||||
): array
|
||||
{
|
||||
$session = $this->sessions->getOrFail($sessionId);
|
||||
|
||||
$archiveSession = null;
|
||||
if (is_string($archiveSessionId) && trim($archiveSessionId) !== '') {
|
||||
$archiveSession = $this->sessions->getOrFail($archiveSessionId);
|
||||
}
|
||||
|
||||
$resolvedAdditionalScreenshots = collect($additionalScreenshotSessions)
|
||||
->filter(fn ($payload) => is_array($payload) && is_string($payload['session_id'] ?? null) && is_string($payload['hash'] ?? null))
|
||||
->values()
|
||||
->map(function (array $payload): array {
|
||||
return [
|
||||
'session' => $this->sessions->getOrFail((string) $payload['session_id']),
|
||||
'hash' => trim((string) $payload['hash']),
|
||||
'file_name' => is_string($payload['file_name'] ?? null) ? $payload['file_name'] : null,
|
||||
];
|
||||
});
|
||||
|
||||
$localCleanup = [];
|
||||
$objectCleanup = [];
|
||||
|
||||
try {
|
||||
$imageOriginal = $this->derivatives->storeOriginal($session->tempPath, $hash, $originalFileName);
|
||||
$localCleanup[] = $imageOriginal['local_path'];
|
||||
$objectCleanup[] = $imageOriginal['object_path'];
|
||||
|
||||
$archiveOriginal = null;
|
||||
if ($archiveSession && is_string($archiveHash) && trim($archiveHash) !== '') {
|
||||
$archiveOriginal = $this->derivatives->storeOriginal($archiveSession->tempPath, trim($archiveHash), $archiveOriginalFileName);
|
||||
$localCleanup[] = $archiveOriginal['local_path'];
|
||||
$objectCleanup[] = $archiveOriginal['object_path'];
|
||||
}
|
||||
|
||||
$additionalScreenshotOriginals = [];
|
||||
foreach ($resolvedAdditionalScreenshots as $index => $payload) {
|
||||
$storedScreenshot = $this->derivatives->storeOriginal(
|
||||
$payload['session']->tempPath,
|
||||
$payload['hash'],
|
||||
$payload['file_name']
|
||||
);
|
||||
$storedScreenshot['variant'] = $this->screenshotVariantName($index + 1);
|
||||
$additionalScreenshotOriginals[] = $storedScreenshot;
|
||||
$localCleanup[] = $storedScreenshot['local_path'];
|
||||
$objectCleanup[] = $storedScreenshot['object_path'];
|
||||
}
|
||||
|
||||
$publicAssets = $this->derivatives->generatePublicDerivatives($session->tempPath, $hash);
|
||||
foreach ($publicAssets as $asset) {
|
||||
$objectCleanup[] = $asset['path'];
|
||||
}
|
||||
|
||||
$dimensions = @getimagesize($session->tempPath);
|
||||
$width = is_array($dimensions) && isset($dimensions[0]) ? (int) $dimensions[0] : 1;
|
||||
$height = is_array($dimensions) && isset($dimensions[1]) ? (int) $dimensions[1] : 1;
|
||||
|
||||
$primaryOriginal = $archiveOriginal ?? $imageOriginal;
|
||||
$downloadFileName = $this->resolveDownloadFileName($primaryOriginal['filename'], $primaryOriginal['ext'], $archiveOriginal ? $archiveOriginalFileName : $originalFileName);
|
||||
|
||||
DB::transaction(function () use ($artworkId, $imageOriginal, $archiveOriginal, $additionalScreenshotOriginals, $publicAssets, $primaryOriginal, $downloadFileName, $hash, $width, $height) {
|
||||
$this->artworkFiles->upsert($artworkId, 'orig', $primaryOriginal['object_path'], $primaryOriginal['mime'], $primaryOriginal['size']);
|
||||
$this->artworkFiles->upsert($artworkId, 'orig_image', $imageOriginal['object_path'], $imageOriginal['mime'], $imageOriginal['size']);
|
||||
|
||||
if ($archiveOriginal) {
|
||||
$this->artworkFiles->upsert($artworkId, 'orig_archive', $archiveOriginal['object_path'], $archiveOriginal['mime'], $archiveOriginal['size']);
|
||||
} else {
|
||||
$this->artworkFiles->deleteVariant($artworkId, 'orig_archive');
|
||||
}
|
||||
|
||||
$this->artworkFiles->deleteScreenshotVariants($artworkId);
|
||||
foreach ($additionalScreenshotOriginals as $screenshot) {
|
||||
$this->artworkFiles->upsert($artworkId, $screenshot['variant'], $screenshot['object_path'], $screenshot['mime'], $screenshot['size']);
|
||||
}
|
||||
|
||||
foreach ($publicAssets as $variant => $asset) {
|
||||
$this->artworkFiles->upsert($artworkId, (string) $variant, $asset['path'], $asset['mime'], $asset['size']);
|
||||
}
|
||||
|
||||
Artwork::query()->whereKey($artworkId)->update([
|
||||
'file_name' => $downloadFileName,
|
||||
'file_path' => $primaryOriginal['object_path'],
|
||||
'file_size' => $primaryOriginal['size'],
|
||||
'mime_type' => $primaryOriginal['mime'],
|
||||
'hash' => $hash,
|
||||
'file_ext' => $primaryOriginal['ext'],
|
||||
'thumb_ext' => 'webp',
|
||||
'width' => max(1, $width),
|
||||
'height' => max(1, $height),
|
||||
]);
|
||||
});
|
||||
|
||||
$this->storage->deleteLocalFile($session->tempPath);
|
||||
if ($archiveSession) {
|
||||
$this->storage->deleteLocalFile($archiveSession->tempPath);
|
||||
}
|
||||
foreach ($resolvedAdditionalScreenshots as $payload) {
|
||||
$this->storage->deleteLocalFile($payload['session']->tempPath);
|
||||
}
|
||||
|
||||
$this->sessions->updateStatus($sessionId, UploadSessionStatus::PROCESSED);
|
||||
$this->sessions->updateProgress($sessionId, 100);
|
||||
if ($archiveSession) {
|
||||
$this->sessions->updateStatus($archiveSession->id, UploadSessionStatus::PROCESSED);
|
||||
$this->sessions->updateProgress($archiveSession->id, 100);
|
||||
}
|
||||
foreach ($resolvedAdditionalScreenshots as $payload) {
|
||||
$this->sessions->updateStatus($payload['session']->id, UploadSessionStatus::PROCESSED);
|
||||
$this->sessions->updateProgress($payload['session']->id, 100);
|
||||
}
|
||||
$this->audit->log($session->userId, 'upload_processed', $session->ip, [
|
||||
'session_id' => $sessionId,
|
||||
'hash' => $hash,
|
||||
'artwork_id' => $artworkId,
|
||||
'archive_session_id' => $archiveSession?->id,
|
||||
'additional_screenshot_session_ids' => $resolvedAdditionalScreenshots->map(fn (array $payload): string => $payload['session']->id)->values()->all(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'orig' => $primaryOriginal['object_path'],
|
||||
'orig_image' => $imageOriginal['object_path'],
|
||||
'orig_archive' => $archiveOriginal['object_path'] ?? null,
|
||||
'screenshots' => array_map(static fn (array $screenshot): array => [
|
||||
'variant' => $screenshot['variant'],
|
||||
'path' => $screenshot['object_path'],
|
||||
], $additionalScreenshotOriginals),
|
||||
'public' => array_map(static fn (array $asset): string => $asset['path'], $publicAssets),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
foreach ($localCleanup as $path) {
|
||||
$this->storage->deleteLocalFile($path);
|
||||
}
|
||||
|
||||
foreach ($objectCleanup as $path) {
|
||||
$this->storage->deleteObject($path);
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function originalHashExists(string $hash): bool
|
||||
{
|
||||
return Artwork::query()
|
||||
->where('hash', $hash)
|
||||
->where('artwork_status', 'published')
|
||||
->whereNotNull('published_at')
|
||||
->exists();
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveDownloadFileName(string $storedFilename, string $ext, ?string $preferredFileName): string
|
||||
{
|
||||
$downloadFileName = $storedFilename;
|
||||
|
||||
if (is_string($preferredFileName) && trim($preferredFileName) !== '') {
|
||||
$candidate = basename(str_replace('\\', '/', $preferredFileName));
|
||||
$candidate = preg_replace('/[\x00-\x1F\x7F]/', '', (string) $candidate) ?? '';
|
||||
$candidate = trim((string) $candidate);
|
||||
|
||||
if ($candidate !== '') {
|
||||
$candidateExt = strtolower((string) pathinfo($candidate, PATHINFO_EXTENSION));
|
||||
if ($candidateExt === '' && $ext !== '') {
|
||||
$candidate .= '.' . $ext;
|
||||
}
|
||||
|
||||
$downloadFileName = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $downloadFileName;
|
||||
}
|
||||
|
||||
private function screenshotVariantName(int $position): string
|
||||
{
|
||||
return sprintf('shot%02d', max(1, min(99, $position)));
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
<?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\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
final class UploadStorageService
|
||||
{
|
||||
public function localOriginalsRoot(): string
|
||||
{
|
||||
return rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
|
||||
}
|
||||
|
||||
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 ensureLocalOriginalHashDirectory(string $hash): string
|
||||
{
|
||||
$segments = $this->hashSegments($hash);
|
||||
$dir = $this->localOriginalsRoot() . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments);
|
||||
|
||||
if (! File::exists($dir)) {
|
||||
File::makeDirectory($dir, 0755, true);
|
||||
}
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
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, '/');
|
||||
}
|
||||
|
||||
public function localOriginalPath(string $hash, string $filename): string
|
||||
{
|
||||
return $this->ensureLocalOriginalHashDirectory($hash) . DIRECTORY_SEPARATOR . ltrim($filename, DIRECTORY_SEPARATOR);
|
||||
}
|
||||
|
||||
public function objectDiskName(): string
|
||||
{
|
||||
return (string) config('uploads.object_storage.disk', 's3');
|
||||
}
|
||||
|
||||
public function objectBasePrefix(): string
|
||||
{
|
||||
return trim((string) config('uploads.object_storage.prefix', 'artworks'), '/');
|
||||
}
|
||||
|
||||
public function objectPathForVariant(string $variant, string $hash, string $filename): string
|
||||
{
|
||||
$segments = implode('/', $this->hashSegments($hash));
|
||||
$basePrefix = $this->objectBasePrefix();
|
||||
$normalizedVariant = trim($variant, '/');
|
||||
|
||||
if ($normalizedVariant === 'original') {
|
||||
return sprintf('%s/original/%s/%s', $basePrefix, $segments, ltrim($filename, '/'));
|
||||
}
|
||||
|
||||
return sprintf('%s/%s/%s/%s', $basePrefix, $normalizedVariant, $segments, ltrim($filename, '/'));
|
||||
}
|
||||
|
||||
public function putObjectFromPath(string $sourcePath, string $objectPath, string $contentType, array $extraOptions = []): void
|
||||
{
|
||||
$stream = fopen($sourcePath, 'rb');
|
||||
if ($stream === false) {
|
||||
throw new RuntimeException('Unable to open source file for object storage upload.');
|
||||
}
|
||||
|
||||
try {
|
||||
$written = Storage::disk($this->objectDiskName())->put($objectPath, $stream, array_merge([
|
||||
'visibility' => 'public',
|
||||
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||
'ContentType' => $contentType,
|
||||
], $extraOptions));
|
||||
} finally {
|
||||
fclose($stream);
|
||||
}
|
||||
|
||||
if ($written !== true) {
|
||||
throw new RuntimeException('Object storage upload failed.');
|
||||
}
|
||||
}
|
||||
|
||||
public function putObjectContents(string $objectPath, string $contents, string $contentType, array $extraOptions = []): void
|
||||
{
|
||||
$written = Storage::disk($this->objectDiskName())->put($objectPath, $contents, array_merge([
|
||||
'visibility' => 'public',
|
||||
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||
'ContentType' => $contentType,
|
||||
], $extraOptions));
|
||||
|
||||
if ($written !== true) {
|
||||
throw new RuntimeException('Object storage upload failed.');
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteObject(string $objectPath): void
|
||||
{
|
||||
if ($objectPath === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::disk($this->objectDiskName())->delete($objectPath);
|
||||
}
|
||||
|
||||
public function readObject(string $objectPath): ?string
|
||||
{
|
||||
if ($objectPath === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$disk = Storage::disk($this->objectDiskName());
|
||||
if (! $disk->exists($objectPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$contents = $disk->get($objectPath);
|
||||
|
||||
return is_string($contents) && $contents !== '' ? $contents : null;
|
||||
}
|
||||
|
||||
public function deleteLocalFile(?string $path): void
|
||||
{
|
||||
if (! is_string($path) || trim($path) === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (File::exists($path)) {
|
||||
File::delete($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function originalHashExists(string $hash): bool
|
||||
{
|
||||
$segments = $this->hashSegments($hash);
|
||||
$dir = $this->sectionPath('original') . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments);
|
||||
|
||||
if (! File::isDirectory($dir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$normalizedHash = strtolower(preg_replace('/[^a-z0-9]/', '', $hash) ?? '');
|
||||
if ($normalizedHash === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$matches = File::glob($dir . DIRECTORY_SEPARATOR . $normalizedHash . '.*');
|
||||
return is_array($matches) && count($matches) > 0;
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
// Use two 2-char segments for directory sharding: first two chars, next two chars.
|
||||
// Result: <section>/<aa>/<bb>/<filename>
|
||||
$segments = [
|
||||
substr($hash, 0, 2),
|
||||
substr($hash, 2, 2),
|
||||
];
|
||||
|
||||
return array_map(static fn (string $part): string => $part === '' ? '00' : $part, $segments);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 !== '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user