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))); } }