[web-downloads] main: Add api to update series files

From: Date: Wed, 19 Nov 2025 01:16:14 +0000
Subject: [web-downloads] main: Add api to update series files
Groups: php.webmaster 
Request: Send a blank email to php-webmaster+get-33361@lists.php.net to get a copy of this message
Author: Shivam Mathur (shivammathur) Date: 2025-11-19T06:43:38+05:30 Commit: https://github.com/php/web-downloads/commit/64cfaa7e1d78f5db8de21b67c74e63493800de16 Raw diff: https://github.com/php/web-downloads/commit/64cfaa7e1d78f5db8de21b67c74e63493800de16.diff Add api to update series files Changed paths: A src/Console/Command/SeriesUpdateCommand.php A src/Http/Controllers/SeriesUpdateController.php A tests/Console/Command/SeriesUpdateCommandTest.php A tests/Http/Controllers/SeriesUpdateControllerTest.php M API.md M routes.php Diff: diff --git a/API.md b/API.md index 9240bff..2933363 100644 --- a/API.md +++ b/API.md @@ -221,6 +221,57 @@ curl -i -X POST \ --- +### POST /api/series-update + +- Auth: Required +- Purpose: Queue an update to a library entry in a series packages file, or remove it entirely. +- Request body (JSON): + - php_version (string, required): Matches ^(\d+\.\d+|master)$. + - vs_version (string, required): Matches ^v[c|s]\d{2}$. + - stability (string, required): Either stable or staging. + - library (string, required): Library identifier to update/remove. + - ref (string, required but may be empty): When non-empty, updates/creates entries named <library>-<ref>-<vs_version>-<arch>.zip for both x86 and x64; when empty, removes the library from both files if present. +- Success: 200 OK, empty body. +- Errors: + - 400 with validation details if the payload is invalid. + - 500 if BUILDS_DIRECTORY is not configured on the server. + +Example (update) + +```bash +curl -i -X POST \ + -H "Authorization: Bearer $AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "php_version": "8.2", + "vs_version": "vs16", + "arch": "x64", + "stability": "stable", + "library": "libxml2", + "ref": "2.9.15" + }' \ + https://downloads.php.net/api/series-update +``` + +Example (remove) + +```bash +curl -i -X POST \ + -H "Authorization: Bearer $AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "php_version": "8.2", + "vs_version": "vs16", + "arch": "x64", + "stability": "stable", + "library": "libxml2", + "ref": "" + }' \ + https://downloads.php.net/api/series-update +``` + +--- + ### POST /api/series-stability - Auth: Required diff --git a/routes.php b/routes.php index 46c959d..b2813b8 100644 --- a/routes.php +++ b/routes.php @@ -9,6 +9,7 @@ use App\Http\Controllers\SeriesDeleteController; use App\Http\Controllers\SeriesInitController; use App\Http\Controllers\SeriesStabilityController; +use App\Http\Controllers\SeriesUpdateController; use App\Http\Controllers\WinlibsController; use App\Router; @@ -21,5 +22,6 @@ $router->registerRoute('/api/php', 'POST', PhpController::class, true); $router->registerRoute('/api/series-init', 'POST', SeriesInitController::class, true); $router->registerRoute('/api/series-delete', 'POST', SeriesDeleteController::class, true); +$router->registerRoute('/api/series-update', 'POST', SeriesUpdateController::class, true); $router->registerRoute('/api/series-stability', 'POST', SeriesStabilityController::class, true); $router->handleRequest(); diff --git a/src/Console/Command/SeriesUpdateCommand.php b/src/Console/Command/SeriesUpdateCommand.php new file mode 100644 index 0000000..f50362f --- /dev/null +++ b/src/Console/Command/SeriesUpdateCommand.php @@ -0,0 +1,142 @@ +<?php +declare(strict_types=1); + +namespace App\Console\Command; + +use App\Console\Command; +use Exception; + +class SeriesUpdateCommand extends Command +{ + protected string $signature = 'series:update --base-directory= --builds-directory='; + protected string $description = 'Update or remove libraries in series package files'; + + protected ?string $baseDirectory = null; + + public function handle(): int + { + try { + $this->baseDirectory = $this->getOption('base-directory'); + if (!$this->baseDirectory) { + throw new Exception('Base directory is required'); + } + + $buildsDirectory = $this->getOption('builds-directory'); + if (!$buildsDirectory) { + throw new Exception('Build directory is required'); + } + + $seriesDirectory = $buildsDirectory . '/series'; + if (!is_dir($seriesDirectory)) { + return Command::SUCCESS; + } + + $tasks = glob($seriesDirectory . '/series-update-*.json'); + $pendingTasks = []; + + foreach ($tasks as $taskFile) { + $lockFile = $taskFile . '.lock'; + if (!file_exists($lockFile)) { + touch($lockFile); + $pendingTasks[] = $taskFile; + } + } + + foreach ($pendingTasks as $taskFile) { + $data = $this->decodeTask($taskFile); + + $this->updateSeriesFiles( + $data['php_version'], + $data['vs_version'], + $data['stability'], + $data['library'], + $data['ref'] + ); + + unlink($taskFile); + unlink($taskFile . '.lock'); + } + + return Command::SUCCESS; + } catch (Exception $e) { + echo $e->getMessage(); + return Command::FAILURE; + } + } + + private function decodeTask(string $taskFile): array + { + $data = json_decode(file_get_contents($taskFile), true, 512, JSON_THROW_ON_ERROR); + + $required = ['php_version', 'vs_version', 'stability', 'library', 'ref']; + foreach ($required as $field) { + if (!array_key_exists($field, $data)) { + throw new Exception("Missing field: $field"); + } + } + + if (!is_string($data['ref'])) { + throw new Exception('Invalid field: ref'); + } + + return $data; + } + + private function updateSeriesFiles( + string $phpVersion, + string $vsVersion, + string $stability, + string $library, + string $ref + ): void { + $seriesDirectory = $this->baseDirectory . '/php-sdk/deps/series'; + if (!is_dir($seriesDirectory)) { + mkdir($seriesDirectory, 0755, true); + } + + $arches = ['x86', 'x64']; + + foreach ($arches as $arch) { + $filePath = $seriesDirectory . "/packages-$phpVersion-$vsVersion-$arch-$stability.txt"; + + $lines = []; + if (file_exists($filePath)) { + $lines = file($filePath, FILE_IGNORE_NEW_LINES); + } + + $refValue = trim($ref); + $package = $refValue === '' ? null : sprintf('%s-%s-%s-%s.zip', $library, $refValue, $vsVersion, $arch); + + $replaced = false; + foreach ($lines as $index => $line) { + if (str_starts_with($line, $library . '-')) { + if ($package === null) { + unset($lines[$index]); + } elseif (!$replaced) { + $lines[$index] = $package; + $replaced = true; + } else { + unset($lines[$index]); + } + } + } + + $lines = array_values($lines); + + if ($package !== null && !$replaced) { + $lines[] = $package; + } + + if (empty($lines)) { + if (file_exists($filePath)) { + unlink($filePath); + } + continue; + } + + $tmpFile = $filePath . '.tmp'; + file_put_contents($tmpFile, implode("\n", $lines), LOCK_EX); + rename($tmpFile, $filePath); + } + } +} \ No newline at end of file diff --git a/src/Http/Controllers/SeriesUpdateController.php b/src/Http/Controllers/SeriesUpdateController.php new file mode 100644 index 0000000..f36d44e --- /dev/null +++ b/src/Http/Controllers/SeriesUpdateController.php @@ -0,0 +1,59 @@ +<?php +declare(strict_types=1); + +namespace App\Http\Controllers; + +use App\Http\BaseController; +use App\Validator; + +class SeriesUpdateController extends BaseController +{ + protected function validate(array $data): bool + { + $validator = new Validator([ + 'php_version' => 'required|string|regex:/^(?:\d+\.\d+|master)$/', + 'vs_version' => 'required|string|regex:/^v[c|s]\d{2}$/', + 'stability' => 'required|string|regex:/^(stable|staging)$/', + 'library' => 'required|string', + 'ref' => 'string', + ]); + + $validator->validate($data); + + if (!$validator->isValid()) { + http_response_code(400); + echo 'Invalid request: ' . $validator; + return false; + } + + return true; + } + + protected function execute(array $data): void + { + $directory = rtrim((string) getenv('BUILDS_DIRECTORY'), '/'); + if ($directory === '') { + http_response_code(500); + echo 'Invalid server configuration: BUILDS_DIRECTORY is not set.'; + return; + } + + $seriesDirectory = $directory . '/series'; + if (!is_dir($seriesDirectory)) { + mkdir($seriesDirectory, 0755, true); + } + + $payload = [ + 'php_version' => $data['php_version'], + 'vs_version' => $data['vs_version'], + 'stability' => $data['stability'], + 'library' => $data['library'], + 'ref' => $data['ref'], + ]; + + $hash = hash('sha256', $data['php_version'] . $data['vs_version'] . $data['library']) . microtime(true); + $file = $seriesDirectory . '/series-update-' . $hash . '.json'; + + file_put_contents($file, json_encode($payload)); + } +} \ No newline at end of file diff --git a/tests/Console/Command/SeriesUpdateCommandTest.php b/tests/Console/Command/SeriesUpdateCommandTest.php new file mode 100644 index 0000000..30bafca --- /dev/null +++ b/tests/Console/Command/SeriesUpdateCommandTest.php @@ -0,0 +1,258 @@ +<?php +declare(strict_types=1); + +namespace Console\Command; + +use App\Console\Command\SeriesUpdateCommand; +use App\Helpers\Helpers; +use PHPUnit\Framework\TestCase; + +class SeriesUpdateCommandTest extends TestCase +{ + private string $baseDirectory; + private string $buildsDirectory; + + protected function setUp(): void + { + parent::setUp(); + $this->baseDirectory = sys_get_temp_dir() . '/series_update_base_' . uniqid(); + $this->buildsDirectory = sys_get_temp_dir() . '/series_update_builds_' . uniqid(); + + mkdir($this->baseDirectory, 0755, true); + mkdir($this->buildsDirectory, 0755, true); + } + + protected function tearDown(): void + { + (new Helpers)->rmdirr($this->baseDirectory); + (new Helpers)->rmdirr($this->buildsDirectory); + parent::tearDown(); + } + + public function testMissingBaseDirectory(): void + { + $command = new SeriesUpdateCommand(); + $command->setOption('builds-directory', $this->buildsDirectory); + + ob_start(); + $result = $command->handle(); + $output = trim(ob_get_clean()); + + $this->assertSame(1, $result); + $this->assertSame('Base directory is required', $output); + } + + public function testMissingBuildsDirectory(): void + { + $command = new SeriesUpdateCommand(); + $command->setOption('base-directory', $this->baseDirectory); + + ob_start(); + $result = $command->handle(); + $output = trim(ob_get_clean()); + + $this->assertSame(1, $result); + $this->assertSame('Build directory is required', $output); + } + + public function testReturnsSuccessWhenNoSeriesDirectory(): void + { + $command = new SeriesUpdateCommand(); + $command->setOption('base-directory', $this->baseDirectory); + $command->setOption('builds-directory', $this->buildsDirectory); + + $result = $command->handle(); + + $this->assertSame(0, $result); + } + + public function testUpdatesExistingLibraryEntry(): void + { + $seriesDirectory = $this->baseDirectory . '/php-sdk/deps/series'; + mkdir($seriesDirectory, 0755, true); + + $filePathX64 = $seriesDirectory . '/packages-8.2-vs16-x64-stable.txt'; + $filePathX86 = $seriesDirectory . '/packages-8.2-vs16-x86-stable.txt'; + + file_put_contents($filePathX64, implode("\n", [ + 'libxml2-2.9.14-vs16-x64.zip', + 'openssl-1.1.1w-vs16-x64.zip', + ])); + + file_put_contents($filePathX86, implode("\n", [ + 'zlib-1.2.13-vs16-x86.zip', + 'libxml2-2.9.14-vs16-x86.zip', + ])); + + $this->createTask([ + 'php_version' => '8.2', + 'vs_version' => 'vs16', + 'stability' => 'stable', + 'library' => 'libxml2', + 'ref' => '2.9.15', + ]); + + $command = new SeriesUpdateCommand(); + $command->setOption('base-directory', $this->baseDirectory); + $command->setOption('builds-directory', $this->buildsDirectory); + + $result = $command->handle(); + + $this->assertSame(0, $result); + + $x64Lines = file($filePathX64, FILE_IGNORE_NEW_LINES); + $this->assertSame([ + 'libxml2-2.9.15-vs16-x64.zip', + 'openssl-1.1.1w-vs16-x64.zip', + ], $x64Lines); + + $x86Lines = file($filePathX86, FILE_IGNORE_NEW_LINES); + $this->assertSame([ + 'zlib-1.2.13-vs16-x86.zip', + 'libxml2-2.9.15-vs16-x86.zip', + ], $x86Lines); + + $this->assertEmpty(glob($this->buildsDirectory . '/series/series-update-*.json')); + } + + public function testRemovesLibraryWhenNoPackageProvided(): void + { + $seriesDirectory = $this->baseDirectory . '/php-sdk/deps/series'; + mkdir($seriesDirectory, 0755, true); + + $filePathX86 = $seriesDirectory . '/packages-8.1-vs17-x86-stable.txt'; + $filePathX64 = $seriesDirectory . '/packages-8.1-vs17-x64-stable.txt'; + + file_put_contents($filePathX86, implode("\n", [ + 'curl-7.88.0-vs17-x86.zip', + 'libzip-1.9.1-vs17-x86.zip', + ])); + + file_put_contents($filePathX64, implode("\n", [ + 'curl-7.88.0-vs17-x64.zip', + 'libzip-1.9.1-vs17-x64.zip', + ])); + + $this->createTask([ + 'php_version' => '8.1', + 'vs_version' => 'vs17', + 'stability' => 'stable', + 'library' => 'libzip', + 'ref' => '', + ]); + + $command = new SeriesUpdateCommand(); + $command->setOption('base-directory', $this->baseDirectory); + $command->setOption('builds-directory', $this->buildsDirectory); + + $result = $command->handle(); + + $this->assertSame(0, $result); + + $x86Lines = file($filePathX86, FILE_IGNORE_NEW_LINES); + $this->assertSame(['curl-7.88.0-vs17-x86.zip'], $x86Lines); + + $x64Lines = file($filePathX64, FILE_IGNORE_NEW_LINES); + $this->assertSame(['curl-7.88.0-vs17-x64.zip'], $x64Lines); + } + + public function testCreatesSeriesFileWhenMissing(): void + { + $this->createTask([ + 'php_version' => '8.3', + 'vs_version' => 'vs17', + 'stability' => 'staging', + 'library' => 'libpq', + 'ref' => '16.0.0', + ]); + + $command = new SeriesUpdateCommand(); + $command->setOption('base-directory', $this->baseDirectory); + $command->setOption('builds-directory', $this->buildsDirectory); + + $result = $command->handle(); + + $this->assertSame(0, $result); + + $filePathX64 = $this->baseDirectory . '/php-sdk/deps/series/packages-8.3-vs17-x64-staging.txt'; + $filePathX86 = $this->baseDirectory . '/php-sdk/deps/series/packages-8.3-vs17-x86-staging.txt'; + + $this->assertFileExists($filePathX64); + $this->assertFileExists($filePathX86); + + $this->assertSame([ + 'libpq-16.0.0-vs17-x64.zip', + ], file($filePathX64, FILE_IGNORE_NEW_LINES)); + + $this->assertSame([ + 'libpq-16.0.0-vs17-x86.zip', + ], file($filePathX86, FILE_IGNORE_NEW_LINES)); + } + + public function testSkipsLockedTask(): void + { + $seriesDirectory = $this->baseDirectory . '/php-sdk/deps/series'; + mkdir($seriesDirectory, 0755, true); + + $filePathX64 = $seriesDirectory . '/packages-8.0-vs16-x64-stable.txt'; + $filePathX86 = $seriesDirectory . '/packages-8.0-vs16-x86-stable.txt'; + file_put_contents($filePathX64, 'sqlite-3.45.0-vs16-x64.zip'); + file_put_contents($filePathX86, 'sqlite-3.45.0-vs16-x86.zip'); + + $taskFile = $this->createTask([ + 'php_version' => '8.0', + 'vs_version' => 'vs16', + 'stability' => 'stable', + 'library' => 'sqlite', + 'ref' => '3.46.0', + ]); + touch($taskFile . '.lock'); + + $command = new SeriesUpdateCommand(); + $command->setOption('base-directory', $this->baseDirectory); + $command->setOption('builds-directory', $this->buildsDirectory); + + $result = $command->handle(); + + $this->assertSame(0, $result); + + $this->assertSame('sqlite-3.45.0-vs16-x64.zip', trim(file_get_contents($filePathX64))); + $this->assertSame('sqlite-3.45.0-vs16-x86.zip', trim(file_get_contents($filePathX86))); + $this->assertFileExists($taskFile); + } + + public function testHandlesCorruptJson(): void + { + $seriesDir = $this->buildsDirectory . '/series'; + mkdir($seriesDir, 0755, true); + + $taskFile = $seriesDir . '/series-update-corrupt.json'; + file_put_contents($taskFile, '{corrupt json'); + + $command = new SeriesUpdateCommand(); + $command->setOption('base-directory', $this->baseDirectory); + $command->setOption('builds-directory', $this->buildsDirectory); + + ob_start(); + $result = $command->handle(); + $output = ob_get_clean(); + + $this->assertSame(1, $result); + $this->assertStringContainsString('Syntax error', $output); + $this->assertFileExists($taskFile); + $this->assertFileExists($taskFile . '.lock'); + } + + private function createTask(array $data): string + { + $seriesDir = $this->buildsDirectory . '/series'; + if (!is_dir($seriesDir)) { + mkdir($seriesDir, 0755, true); + } + + $taskFile = $seriesDir . '/series-update-' . uniqid() . '.json'; + file_put_contents($taskFile, json_encode($data)); + + return $taskFile; + } +} \ No newline at end of file diff --git a/tests/Http/Controllers/SeriesUpdateControllerTest.php b/tests/Http/Controllers/SeriesUpdateControllerTest.php new file mode 100644 index 0000000..56e6672 --- /dev/null +++ b/tests/Http/Controllers/SeriesUpdateControllerTest.php @@ -0,0 +1,106 @@ +<?php +declare(strict_types=1); + +namespace Http\Controllers; + +use App\Helpers\Helpers; +use App\Http\Controllers\SeriesUpdateController; +use PHPUnit\Framework\TestCase; + +class SeriesUpdateControllerTest extends TestCase +{ + private string $buildsDirectory; + private ?string $originalBuildsDirectory; + + protected function setUp(): void + { + parent::setUp(); + $this->buildsDirectory = sys_get_temp_dir() . '/series_update_controller_' . uniqid(); + mkdir($this->buildsDirectory, 0755, true); + + $this->originalBuildsDirectory = getenv('BUILDS_DIRECTORY') ?: null; + putenv('BUILDS_DIRECTORY=' . $this->buildsDirectory); + } + + protected function tearDown(): void + { + putenv($this->originalBuildsDirectory === null ? 'BUILDS_DIRECTORY' : 'BUILDS_DIRECTORY=' . $this->originalBuildsDirectory); + (new Helpers)->rmdirr($this->buildsDirectory); + parent::tearDown(); + } + + public function testEnqueuesUpdateTask(): void + { + $payload = [ + 'php_version' => '8.2', + 'vs_version' => 'vs16', + 'stability' => 'stable', + 'library' => 'zlib', + 'ref' => '1.2.13', + ]; + + $inputPath = $this->createInputFile($payload); + + $controller = new SeriesUpdateController($inputPath); + $controller->handle(); + unlink($inputPath); + + $taskFiles = glob($this->buildsDirectory . '/series/series-update-*.json'); + $this->assertNotEmpty($taskFiles); + + $taskData = json_decode(file_get_contents($taskFiles[0]), true, 512, JSON_THROW_ON_ERROR); + $this->assertSame($payload, $taskData); + } + + public function testEnqueuesDeleteTaskWithoutPackage(): void + { + $payload = [ + 'php_version' => '8.1', + 'vs_version' => 'vs17', + 'stability' => 'staging', + 'library' => 'libcurl', + 'ref' => '', + ]; + + $inputPath = $this->createInputFile($payload); + + $controller = new SeriesUpdateController($inputPath); + $controller->handle(); + unlink($inputPath); + + $taskFiles = glob($this->buildsDirectory . '/series/series-update-*.json'); + $this->assertNotEmpty($taskFiles); + $taskData = json_decode(file_get_contents($taskFiles[0]), true, 512, JSON_THROW_ON_ERROR); + + $this->assertSame($payload, $taskData); + } + + public function testRejectsInvalidPackageName(): void + { + $payload = [ + 'php_version' => '8.0', + 'vs_version' => 'vs16', + 'stability' => 'stable', + 'library' => 'openssl', + // ref missing entirely + ]; + + $inputPath = $this->createInputFile($payload); + $controller = new SeriesUpdateController($inputPath); + + ob_start(); + $controller->handle(); + $output = ob_get_clean(); + unlink($inputPath); + + $this->assertStringContainsString('The ref field must be a string.', $output); + $this->assertEmpty(glob($this->buildsDirectory . '/series/series-update-*.json')); + } + + private function createInputFile(array $data): string + { + $path = tempnam(sys_get_temp_dir(), 'series-update-input-'); + file_put_contents($path, json_encode($data)); + return $path; + } +} \ No newline at end of file

« previous php.webmaster (#33361) next »