`. Once all * the parts have been uploaded, a final destination file is * being created from all the stored parts (appending one by one). * * @author Buster "Silver Eagle" Neece * @email buster@busterneece.com * * @author Gregory Chris (http://online-php.com) * @email www.online.php@gmail.com * * @editor Bivek Joshi (http://www.bivekjoshi.com.np) * @email meetbivek@gmail.com */ declare(strict_types=1); namespace App\Service; use App\Exception; use App\Http\Response; use App\Http\ServerRequest; use App\Service\Flow\UploadedFile; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\UploadedFileInterface; use RuntimeException; use Symfony\Component\Filesystem\Filesystem; use const SCANDIR_SORT_NONE; class Flow { /** * Process the request and return a response if necessary, or the completed file details if successful. */ public static function process( ServerRequest $request, Response $response, string $tempDir = null ): UploadedFile|ResponseInterface { if (null === $tempDir) { $tempDir = sys_get_temp_dir() . '/uploads'; (new Filesystem())->mkdir($tempDir); } $params = $request->getParams(); // Handle a regular file upload that isn't using flow. if (empty($params['flowTotalChunks']) || empty($params['flowIdentifier'])) { // Prompt an upload if this is indeed a mistaken Flow request. if ('GET' === $request->getMethod()) { return $response->withStatus(204, 'No Content'); } return self::handleStandardUpload($request, $tempDir); } $flowIdentifier = $params['flowIdentifier']; $flowChunkNumber = (int)($params['flowChunkNumber'] ?? 1); $targetSize = (int)($params['flowTotalSize'] ?? 0); $targetChunks = (int)($params['flowTotalChunks']); $flowFilename = $params['flowFilename'] ?? ($flowIdentifier); // init the destination file (format .part<#chunk> $chunkBaseDir = $tempDir . '/' . $flowIdentifier; $chunkPath = $chunkBaseDir . '/' . $flowIdentifier . '.part' . $flowChunkNumber; $currentChunkSize = (int)($params['flowCurrentChunkSize'] ?? 0); // Check if request is GET and the requested chunk exists or not. This makes testChunks work if ('GET' === $request->getMethod()) { // Force a reupload of the last chunk if all chunks are uploaded, to trigger processing below. if ( $flowChunkNumber !== $targetChunks && is_file($chunkPath) && filesize($chunkPath) === $currentChunkSize ) { return $response->withStatus(200, 'OK'); } return $response->withStatus(204, 'No Content'); } $files = $request->getUploadedFiles(); if (empty($files)) { throw new Exception\NoFileUploadedException(); } /** @var UploadedFileInterface $file */ $file = reset($files); if ($file->getError() !== UPLOAD_ERR_OK) { throw new RuntimeException('Error ' . $file->getError() . ' in file ' . $flowFilename); } // the file is stored in a temporary directory (new Filesystem())->mkdir($chunkBaseDir); if ($file->getSize() !== $currentChunkSize) { throw new RuntimeException( sprintf( 'File size of %s does not match expected size of %s', $file->getSize(), $currentChunkSize ) ); } $file->moveTo($chunkPath); if ($flowChunkNumber === $targetChunks && self::allPartsExist($chunkBaseDir, $targetSize, $targetChunks)) { return self::createFileFromChunks( $tempDir, $chunkBaseDir, $flowIdentifier, $flowFilename, $targetChunks ); } // Return an OK status to indicate that the chunk upload itself succeeded. return $response->withStatus(200, 'OK'); } protected static function handleStandardUpload( ServerRequest $request, string $tempDir ): UploadedFile { $files = $request->getUploadedFiles(); if (empty($files)) { throw new Exception\NoFileUploadedException(); } /** @var UploadedFileInterface $file */ $file = reset($files); if ($file->getError() !== UPLOAD_ERR_OK) { throw new RuntimeException('Uploaded file error code: ' . $file->getError()); } $uploadedFile = new UploadedFile($file->getClientFilename(), null, $tempDir); $file->moveTo($uploadedFile->getUploadedPath()); return $uploadedFile; } /** * Check if all parts exist and are uploaded. * * @param string $chunkBaseDir * @param int $targetSize * @param int $targetChunkNumber */ protected static function allPartsExist( string $chunkBaseDir, int $targetSize, int $targetChunkNumber ): bool { $chunkSize = 0; $chunkNumber = 0; foreach (array_diff(scandir($chunkBaseDir, SCANDIR_SORT_NONE) ?: [], ['.', '..']) as $file) { $chunkSize += filesize($chunkBaseDir . '/' . $file); $chunkNumber++; } return ($chunkSize === $targetSize && $chunkNumber === $targetChunkNumber); } protected static function createFileFromChunks( string $tempDir, string $chunkBaseDir, string $chunkIdentifier, string $originalFileName, int $numChunks ): UploadedFile { $uploadedFile = new UploadedFile($originalFileName, null, $tempDir); $finalPath = $uploadedFile->getUploadedPath(); $fp = fopen($finalPath, 'wb+'); if (false === $fp) { throw new RuntimeException( sprintf( 'Could not open final path "%s" for writing.', $finalPath ) ); } for ($i = 1; $i <= $numChunks; $i++) { $chunkContents = file_get_contents($chunkBaseDir . '/' . $chunkIdentifier . '.part' . $i); if (empty($chunkContents)) { throw new RuntimeException( sprintf( 'Could not load chunk "%d" for writing.', $i ) ); } fwrite($fp, $chunkContents); } fclose($fp); // rename the temporary directory (to avoid access from other // concurrent chunk uploads) and then delete it. if (rename($chunkBaseDir, $chunkBaseDir . '_UNUSED')) { self::rrmdir($chunkBaseDir . '_UNUSED'); } else { self::rrmdir($chunkBaseDir); } return $uploadedFile; } /** * Delete a directory RECURSIVELY * * @param string $dir - directory path * * @link http://php.net/manual/en/function.rmdir.php */ protected static function rrmdir(string $dir): void { if (is_dir($dir)) { $objects = array_diff(scandir($dir, SCANDIR_SORT_NONE) ?: [], ['.', '..']); foreach ($objects as $object) { if (is_dir($dir . '/' . $object)) { self::rrmdir($dir . '/' . $object); } else { unlink($dir . '/' . $object); } } rmdir($dir); } } }