$metadata */ public function init(string $ownerKey, array $metadata): string { $uploadId = (string) Str::uuid(); $tempDir = $this->sessionDir($uploadId); if (! is_dir($tempDir)) { mkdir($tempDir, 0755, true); } $payload = array_merge($metadata, [ 'owner_key' => $ownerKey, 'chunks_received' => [], 'created_at' => now()->toIso8601String(), ]); $this->writeMetadata($uploadId, $payload); return $uploadId; } public function receiveChunk(string $uploadId, string $ownerKey, int $chunkIndex, UploadedFile $chunk, int $expectedSize): int { $metadata = $this->metadata($uploadId, $ownerKey); $tempDir = $this->sessionDir($uploadId); $actualSize = (int) $chunk->getSize(); if ($expectedSize > 0 && $actualSize !== $expectedSize) { throw new RuntimeException("Chunk size mismatch: expected {$expectedSize}, got {$actualSize}."); } $chunkPath = "{$tempDir}/chunk_{$chunkIndex}"; $chunk->move($tempDir, "chunk_{$chunkIndex}"); if (! file_exists($chunkPath) || filesize($chunkPath) !== $actualSize) { throw new RuntimeException('Chunk save failed.'); } $metadata['chunks_received'][] = $chunkIndex; $metadata['chunks_received'] = array_values(array_unique($metadata['chunks_received'])); sort($metadata['chunks_received']); $this->writeMetadata($uploadId, $metadata); return count($metadata['chunks_received']); } /** @return array{path: string, filename: string, size: int, metadata: array} */ public function finalize(string $uploadId, string $ownerKey, int $totalChunks): array { $metadata = $this->metadata($uploadId, $ownerKey); $tempDir = $this->sessionDir($uploadId); if (count($metadata['chunks_received']) !== $totalChunks) { throw new RuntimeException( 'Missing chunks. Expected '.$totalChunks.', received '.count($metadata['chunks_received']).'.', ); } $finalPath = "{$tempDir}/final_".Str::random(8); $finalFile = fopen($finalPath, 'wb'); if ($finalFile === false) { throw new RuntimeException('Could not create assembled file.'); } for ($i = 0; $i < $totalChunks; $i++) { $chunkPath = "{$tempDir}/chunk_{$i}"; if (! file_exists($chunkPath)) { fclose($finalFile); throw new RuntimeException("Chunk {$i} is missing."); } $chunkHandle = fopen($chunkPath, 'rb'); if ($chunkHandle === false) { fclose($finalFile); throw new RuntimeException("Could not read chunk {$i}."); } while (! feof($chunkHandle)) { $buffer = fread($chunkHandle, 8192); if ($buffer === false) { break; } fwrite($finalFile, $buffer); } fclose($chunkHandle); } fclose($finalFile); $combinedSize = (int) filesize($finalPath); $expectedSize = (int) ($metadata['filesize'] ?? 0); if ($expectedSize > 0 && abs($combinedSize - $expectedSize) > 1024) { @unlink($finalPath); throw new RuntimeException("File size mismatch. Expected {$expectedSize} bytes, got {$combinedSize} bytes."); } $metadata['assembled_path'] = $finalPath; $metadata['assembled_size'] = $combinedSize; $this->writeMetadata($uploadId, $metadata); return [ 'path' => $finalPath, 'filename' => basename((string) ($metadata['filename'] ?? 'upload.bin')), 'size' => $combinedSize, 'metadata' => $metadata, ]; } public function toUploadedFile(string $uploadId, string $ownerKey): UploadedFile { $metadata = $this->metadata($uploadId, $ownerKey); $path = (string) ($metadata['assembled_path'] ?? ''); if ($path === '' || ! is_file($path)) { throw new RuntimeException('Assembled upload is not ready.'); } return new UploadedFile( $path, basename((string) ($metadata['filename'] ?? 'upload.bin')), null, null, true, ); } public function cleanup(string $uploadId): void { $tempDir = $this->sessionDir($uploadId); if (! is_dir($tempDir)) { return; } foreach (glob("{$tempDir}/*") ?: [] as $file) { @unlink($file); } @rmdir($tempDir); } /** @return array */ public function metadata(string $uploadId, string $ownerKey): array { $path = $this->metadataPath($uploadId); if (! file_exists($path)) { throw new RuntimeException('Upload session not found or expired.'); } $metadata = json_decode((string) file_get_contents($path), true); if (! is_array($metadata) || ($metadata['owner_key'] ?? '') !== $ownerKey) { throw new RuntimeException('Upload session not found or unauthorized.'); } return $metadata; } private function sessionDir(string $uploadId): string { return $this->baseDir().'/'.$uploadId; } private function metadataPath(string $uploadId): string { return $this->sessionDir($uploadId).'/metadata.json'; } /** @param array $metadata */ private function writeMetadata(string $uploadId, array $metadata): void { file_put_contents($this->metadataPath($uploadId), json_encode($metadata)); } }