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