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

[email protected] (Shivam Mathur)
Newsgroups php.webmaster
Message-ID <[email protected]>
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
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.