From b9ff255878fc19b560621cbd50206285b2d80882 Mon Sep 17 00:00:00 2001 From: F4ilji Date: Sun, 5 Jul 2026 13:26:10 +0500 Subject: [PATCH] fix(vikon): use cURL streaming for ZIP downloads to avoid memory/timeout issues --- .phpunit.result.cache | 2 +- .../Actions/UpdateCoreAction.php | 11 ++-- .../Actions/UpdatePartAction.php | 11 ++-- .../VikonIntegration/Tasks/HttpTask.php | 61 +++++++++++++++++++ .../Tests/Unit/UpdatePartActionTest.php | 6 +- 5 files changed, 76 insertions(+), 15 deletions(-) diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 18bef70..fedd05c 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":{"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_replaces_existing_entry":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_creates_new_entry_when_not_exists":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_returns_false_on_path_traversal":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_completed_when_status_is_completed":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_failed_when_status_is_failed":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_timeout_after_max_attempts":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_requests_generation_and_polls_status":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_throws_on_generation_failure":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_throws_on_poll_failure":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_restore_after_fail_removes_new_and_restores_old":8,"App\\Containers\\VikonIntegration\\Tests\\Feature\\UpdatePartTest::test_update_part_validates_module_id":8,"App\\Containers\\VikonIntegration\\Tests\\Feature\\UpdatePartTest::test_update_part_validates_part":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_rejects_invalid_part":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_rejects_invalid_module_id":7},"times":{"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_replaces_existing_entry":0.018,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_creates_new_entry_when_not_exists":0.002,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_returns_false_on_path_traversal":0.002,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_completed_when_status_is_completed":0.027,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_failed_when_status_is_failed":0.001,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_timeout_after_max_attempts":0.002,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_rejects_invalid_module_id":0.003,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_rejects_invalid_part":0.011,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_requests_generation_and_polls_status":0.003,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_throws_on_generation_failure":0.001,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_throws_on_poll_failure":0.001,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_restore_after_fail_removes_new_and_restores_old":0.001,"App\\Containers\\VikonIntegration\\Tests\\Feature\\UpdatePartTest::test_update_part_requires_authentication":0.069,"App\\Containers\\VikonIntegration\\Tests\\Feature\\UpdatePartTest::test_update_part_validates_module_id":0.022,"App\\Containers\\VikonIntegration\\Tests\\Feature\\UpdatePartTest::test_update_part_validates_part":0.01,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_completed_when_status_is_1":0.023,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_failed_when_status_is_negative_1":0}} \ No newline at end of file +{"version":2,"defects":{"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_replaces_existing_entry":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_creates_new_entry_when_not_exists":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_returns_false_on_path_traversal":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_completed_when_status_is_completed":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_failed_when_status_is_failed":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_timeout_after_max_attempts":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_requests_generation_and_polls_status":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_throws_on_generation_failure":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_throws_on_poll_failure":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_restore_after_fail_removes_new_and_restores_old":8,"App\\Containers\\VikonIntegration\\Tests\\Feature\\UpdatePartTest::test_update_part_validates_module_id":8,"App\\Containers\\VikonIntegration\\Tests\\Feature\\UpdatePartTest::test_update_part_validates_part":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_rejects_invalid_part":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_rejects_invalid_module_id":7},"times":{"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_replaces_existing_entry":0.016,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_creates_new_entry_when_not_exists":0.001,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_returns_false_on_path_traversal":0.001,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_completed_when_status_is_completed":0.027,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_failed_when_status_is_failed":0.001,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_timeout_after_max_attempts":0.002,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_rejects_invalid_module_id":0.003,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_rejects_invalid_part":0.011,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_requests_generation_and_polls_status":0.004,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_throws_on_generation_failure":0.001,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_throws_on_poll_failure":0.001,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_restore_after_fail_removes_new_and_restores_old":0.001,"App\\Containers\\VikonIntegration\\Tests\\Feature\\UpdatePartTest::test_update_part_requires_authentication":0.048,"App\\Containers\\VikonIntegration\\Tests\\Feature\\UpdatePartTest::test_update_part_validates_module_id":0.027,"App\\Containers\\VikonIntegration\\Tests\\Feature\\UpdatePartTest::test_update_part_validates_part":0.01,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_completed_when_status_is_1":0.02,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_failed_when_status_is_negative_1":0.001}} \ No newline at end of file diff --git a/app/Containers/VikonIntegration/Actions/UpdateCoreAction.php b/app/Containers/VikonIntegration/Actions/UpdateCoreAction.php index e73e513..10648a2 100644 --- a/app/Containers/VikonIntegration/Actions/UpdateCoreAction.php +++ b/app/Containers/VikonIntegration/Actions/UpdateCoreAction.php @@ -43,15 +43,14 @@ class UpdateCoreAction return; } - $zipContent = $this->http->downloadWithToken( - 'pull_updates/generateEmptyModuleCore/' . $moduleId, - $accessToken - ); - File::makeDirectory($tempPath, 0755, true, true); $zipFile = $tempPath . '/module.zip'; - file_put_contents($zipFile, $zipContent); + $this->http->downloadToFile( + 'pull_updates/generateEmptyModuleCore/' . $moduleId, + $accessToken, + $zipFile + ); $this->extractZip($zipFile, $tempPath); diff --git a/app/Containers/VikonIntegration/Actions/UpdatePartAction.php b/app/Containers/VikonIntegration/Actions/UpdatePartAction.php index 9b88386..0641ebc 100644 --- a/app/Containers/VikonIntegration/Actions/UpdatePartAction.php +++ b/app/Containers/VikonIntegration/Actions/UpdatePartAction.php @@ -62,16 +62,15 @@ class UpdatePartAction } // Step 4: Download ZIP - $zipContent = $this->http->downloadWithToken( - "pull_updates/downloadPartByNewCoreResult?operation_identity={$operationIdentity}&part={$part}", - $accessToken - ); - $tempPath = $this->storagePath . '/temp/' . $config['path'] . '_part'; File::makeDirectory($tempPath, 0755, true, true); $zipFile = $tempPath . '/part.zip'; - file_put_contents($zipFile, $zipContent); + $this->http->downloadToFile( + "pull_updates/downloadPartByNewCoreResult?operation_identity={$operationIdentity}&part={$part}", + $accessToken, + $zipFile + ); $zip = new ZipArchive(); if ($zip->open($zipFile) !== true) { diff --git a/app/Containers/VikonIntegration/Tasks/HttpTask.php b/app/Containers/VikonIntegration/Tasks/HttpTask.php index 4cf2ad7..e9cda2e 100644 --- a/app/Containers/VikonIntegration/Tasks/HttpTask.php +++ b/app/Containers/VikonIntegration/Tasks/HttpTask.php @@ -54,6 +54,7 @@ class HttpTask $response = $this->client() ->withToken($token) ->withHeaders(['Accept-Encoding' => 'zip, gzip']) + ->timeout(300) ->get($url); if ($response->failed()) { @@ -63,6 +64,66 @@ class HttpTask return $response->body(); } + /** + * Download a file directly to disk using cURL streaming (avoids memory limit for large files). + */ + public function downloadToFile(string $endpoint, string $token, string $filePath, string $service = 'api'): void + { + $url = $this->url($endpoint, $service); + + $headers = [ + 'Authorization: Bearer ' . $token, + 'Accept-Encoding: zip, gzip', + ]; + + if (config('vikon.domain_resolve')) { + $resolve = [ + 'db-nica.ru:443:' . config('vikon.vikon_domain_resolve_ip'), + 'file.db-nica.ru:443:' . config('vikon.fm_domain_resolve_ip'), + ]; + } + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => false, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => 0, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 300, + CURLOPT_CONNECTTIMEOUT => 30, + ]); + + if (!empty($resolve)) { + curl_setopt($ch, CURLOPT_RESOLVE, $resolve); + } + + $fp = fopen($filePath, 'w'); + if (!$fp) { + curl_close($ch); + throw new \RuntimeException("Cannot open file for writing: {$filePath}"); + } + + curl_setopt($ch, CURLOPT_FILE, $fp); + curl_exec($ch); + + $error = curl_error($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + fclose($fp); + + if ($error) { + @unlink($filePath); + throw new \RuntimeException('cURL download error: ' . $error); + } + + if ($httpCode !== 200) { + @unlink($filePath); + throw new \RuntimeException("Download failed with HTTP {$httpCode}"); + } + } + private function client(): PendingRequest { $client = Http::timeout($this->timeout) diff --git a/app/Containers/VikonIntegration/Tests/Unit/UpdatePartActionTest.php b/app/Containers/VikonIntegration/Tests/Unit/UpdatePartActionTest.php index 8f0dfba..3f1d0c9 100644 --- a/app/Containers/VikonIntegration/Tests/Unit/UpdatePartActionTest.php +++ b/app/Containers/VikonIntegration/Tests/Unit/UpdatePartActionTest.php @@ -90,9 +90,11 @@ class UpdatePartActionTest extends TestCase $zipContent = file_get_contents($zipPath); - $http->shouldReceive('downloadWithToken') + $http->shouldReceive('downloadToFile') ->once() - ->andReturn($zipContent); + ->andReturnUsing(function ($endpoint, $token, $filePath) use ($zipContent) { + file_put_contents($filePath, $zipContent); + }); $fs->shouldReceive('validateFileTypes') ->once()