[PHP-WEBMASTER] [web-downloads] main: Add endpoint to list builds

[email protected] (Shivam Mathur)
Newsgroups php.webmaster
Message-ID <[email protected]>
Author: Shivam Mathur (shivammathur)
Date: 2025-11-10T11:37:03+05:30

Commit: https://github.com/php/web-downloads/commit/daab2fa752eadce4f936b52577c2d972a728b4df
Raw diff: https://github.com/php/web-downloads/commit/daab2fa752eadce4f936b52577c2d972a728b4df.diff

Add endpoint to list builds

Changed paths:
  A  src/Http/Controllers/ListBuildsController.php
  A  tests/Http/Controllers/ListBuildsControllerTest.php
  M  API.md
  M  routes.php


Diff:

diff --git a/API.md b/API.md
index 7056060..46917be 100644
--- a/API.md
+++ b/API.md
@@ -25,6 +25,26 @@
 
 ---
 
+### GET /api/list-builds
+
+- Auth: Required
+- Purpose: Enumerate the files under `BUILDS_DIRECTORY` so operators can inspect available build artifacts.
+- Request body: none (GET request).
+- Success: `200 OK` with JSON payload `{ "builds": [ { "path": "relative/path", "size": 1234, "modified": "2025-09-30T12:34:56+00:00" }, ... ] }`.
+- Errors:
+    - `401` if the bearer token is missing or invalid.
+    - `500` with `{ "error": "Builds directory not configured or missing." }` if `BUILDS_DIRECTORY` is unset or the directory does not exist.
+
+Example
+
+```bash
+curl -i -X GET \
+    -H "Authorization: Bearer $AUTH_TOKEN" \
+    https://downloads.php.net/api/list-builds
+```
+
+---
+
 ### POST /api/php
 
 - Auth: Required
diff --git a/routes.php b/routes.php
index bdf6001..9b707a4 100644
--- a/routes.php
+++ b/routes.php
@@ -2,6 +2,7 @@
 declare(strict_types=1);
 
 use App\Http\Controllers\IndexController;
+use App\Http\Controllers\ListBuildsController;
 use App\Http\Controllers\PeclController;
 use App\Http\Controllers\PhpController;
 use App\Http\Controllers\SeriesDeleteController;
@@ -12,6 +13,7 @@
 
 $router = new Router();
 $router->registerRoute('/api', 'GET', IndexController::class);
+$router->registerRoute('/api/list-builds', 'GET', ListBuildsController::class, true);
 $router->registerRoute('/api/pecl', 'POST', PeclController::class, true);
 $router->registerRoute('/api/winlibs', 'POST', WinlibsController::class, true);
 $router->registerRoute('/api/php', 'POST', PhpController::class, true);
diff --git a/src/Http/Controllers/ListBuildsController.php b/src/Http/Controllers/ListBuildsController.php
new file mode 100644
index 0000000..3be3732
--- /dev/null
+++ b/src/Http/Controllers/ListBuildsController.php
@@ -0,0 +1,79 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Http\Controllers;
+
+use App\Http\ControllerInterface;
+use FilesystemIterator;
+use JsonException;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+
+class ListBuildsController implements ControllerInterface
+{
+    public function __construct(private ?string $buildsDirectory = null)
+    {
+        if ($this->buildsDirectory === null) {
+            $this->buildsDirectory = getenv('BUILDS_DIRECTORY') ?: '';
+        }
+    }
+
+    public function handle(): void
+    {
+        if ($this->buildsDirectory === '' || !is_dir($this->buildsDirectory)) {
+            http_response_code(500);
+            $this->outputJson(['error' => 'Builds directory not configured or missing.']);
+            return;
+        }
+
+        $builds = $this->collectBuilds($this->buildsDirectory);
+
+        http_response_code(200);
+        $this->outputJson(['builds' => $builds]);
+    }
+
+    private function collectBuilds(string $root): array
+    {
+        $entries = [];
+
+            $normalizedRoot = rtrim($root, DIRECTORY_SEPARATOR);
+
+            $iterator = new RecursiveIteratorIterator(
+                new RecursiveDirectoryIterator($normalizedRoot, FilesystemIterator::SKIP_DOTS),
+            RecursiveIteratorIterator::LEAVES_ONLY
+        );
+
+        foreach ($iterator as $fileInfo) {
+            if (!$fileInfo->isFile()) {
+                continue;
+            }
+
+                $relativePath = substr($fileInfo->getPathname(), strlen($normalizedRoot) + 1);
+
+            $entries[] = [
+                'path' => $relativePath,
+                'size' => $fileInfo->getSize(),
+                'modified' => gmdate('c', $fileInfo->getMTime()),
+            ];
+        }
+
+        usort($entries, static fn (array $a, array $b): int => strcmp($a['path'], $b['path']));
+
+        return $entries;
+    }
+
+    private function outputJson(array $payload): void
+    {
+        try {
+            $json = json_encode($payload, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
+        } catch (JsonException) {
+            http_response_code(500);
+            header('Content-Type: application/json');
+            echo '{"error":"Failed to encode response."}';
+            return;
+        }
+
+        header('Content-Type: application/json');
+        echo $json;
+    }
+}
diff --git a/tests/Http/Controllers/ListBuildsControllerTest.php b/tests/Http/Controllers/ListBuildsControllerTest.php
new file mode 100644
index 0000000..9b4077e
--- /dev/null
+++ b/tests/Http/Controllers/ListBuildsControllerTest.php
@@ -0,0 +1,90 @@
+<?php
+declare(strict_types=1);
+
+namespace Http\Controllers;
+
+use App\Http\Controllers\ListBuildsController;
+use JsonException;
+use PHPUnit\Framework\TestCase;
+
+class ListBuildsControllerTest extends TestCase
+{
+    public function testHandleOutputsBuildListing(): void
+    {
+        $tempDir = $this->createTempBuildDirectory();
+
+        $controller = new ListBuildsController($tempDir);
+
+        http_response_code(200);
+        ob_start();
+        $controller->handle();
+        $output = ob_get_clean();
+
+        static::assertNotFalse($output);
+
+        $data = $this->decodeJson($output);
+
+        static::assertSame(200, http_response_code());
+        static::assertArrayHasKey('builds', $data);
+        static::assertCount(2, $data['builds']);
+        static::assertSame('php/build-one.zip', $data['builds'][0]['path']);
+        static::assertSame('winlibs/run/info.json', $data['builds'][1]['path']);
+
+        $this->removeDirectory($tempDir);
+    }
+
+    public function testHandleReturnsErrorWhenDirectoryMissing(): void
+    {
+        $controller = new ListBuildsController('/path/to/missing/builds');
+
+        http_response_code(200);
+        ob_start();
+        $controller->handle();
+        $output = ob_get_clean();
+
+        $data = $this->decodeJson($output);
+
+        static::assertSame(500, http_response_code());
+        static::assertSame('Builds directory not configured or missing.', $data['error']);
+    }
+
+    private function decodeJson(string $json): array
+    {
+        try {
+            return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
+        } catch (JsonException $exception) {
+            static::fail('Response is not valid JSON: ' . $exception->getMessage());
+        }
+
+        return [];
+    }
+
+    private function createTempBuildDirectory(): string
+    {
+        $base = sys_get_temp_dir() . '/list-builds-' . uniqid();
+        mkdir($base . '/php', 0755, true);
+        mkdir($base . '/winlibs/run', 0755, true);
+
+        file_put_contents($base . '/php/build-one.zip', 'fake-zip-data');
+        touch($base . '/php/build-one.zip', 1730000000);
+
+        file_put_contents($base . '/winlibs/run/info.json', '{}');
+        touch($base . '/winlibs/run/info.json', 1730003600);
+
+        return $base;
+    }
+
+    private function removeDirectory(string $path): void
+    {
+        $items = new \RecursiveIteratorIterator(
+            new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
+            \RecursiveIteratorIterator::CHILD_FIRST
+        );
+
+        foreach ($items as $item) {
+            $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
+        }
+
+        rmdir($path);
+    }
+}
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.