From 723d6d3d614ff295d55ef05dae5fc0610200cc11 Mon Sep 17 00:00:00 2001 From: RackNar Date: Tue, 17 Mar 2026 15:47:32 +0100 Subject: [PATCH] Initial commit --- .env.example | 6 + .gitignore | 9 + LICENSE | 21 ++ README.md | 81 +++++++ bin/smoke-test.sh | 19 ++ src/public/.htaccess | 15 ++ src/public/index.php | 487 +++++++++++++++++++++++++++++++++++++++++++ tests/run.php | 324 ++++++++++++++++++++++++++++ var/inbox/.gitignore | 2 + 9 files changed, 964 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100755 bin/smoke-test.sh create mode 100644 src/public/.htaccess create mode 100644 src/public/index.php create mode 100644 tests/run.php create mode 100644 var/inbox/.gitignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b106e78 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +APP_ENV=prod +INBOX_SECRET=change-me +INBOX_MAX_BODY_BYTES=10485760 +INBOX_STORAGE_DIR=var/inbox +INBOX_STORAGE_BACKEND=ndjson +INBOX_CORS_ALLOW_ORIGINS=* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e9b290 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +.DS_Store +Thumbs.db + +/var/inbox/* +!/var/inbox/.gitignore +/var/test-*/ + +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7a0f967 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sebastian Strobl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5138588 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# Share Inbox Service + +Kleiner privater PHP-Service zum Einsammeln von Inhalten per HTTP und zum späteren Abrufen als JSON. + +## Hinweis zu KI-Unterstützung + +Hinweis: Teile dieses Repositories wurden unter Einsatz generativer KI erstellt oder überarbeitet. Die Verantwortung für Inhalt und Veröffentlichung liegt beim Maintainer. + +## Lizenz + +Dieses Repository steht unter der [MIT-Lizenz](LICENSE). + +## Funktionen + +- `POST /share` speichert eingehende Daten. +- `GET /inbox` listet gespeicherte Einträge. +- Storage kann per Flag zwischen `ndjson` und `sqlite` umgeschaltet werden. + +## Voraussetzungen + +- PHP 8.1+ mit aktiviertem `pdo_sqlite`, falls `sqlite` genutzt werden soll + +## Konfiguration + +1. Beispielkonfiguration kopieren: + +```sh +cp .env.example .env +``` + +2. Wichtige Variablen: + +- `INBOX_SECRET`: Shared Secret für `POST /share` +- `INBOX_MAX_BODY_BYTES`: maximales Request-Body-Limit +- `INBOX_STORAGE_DIR`: Speicherort außerhalb des Webroots, Standard `var/inbox` +- `INBOX_STORAGE_BACKEND`: `ndjson` oder `sqlite` +- `INBOX_CORS_ALLOW_ORIGINS`: kommaseparierte Liste erlaubter Origins oder `*` + +## Lokal starten + +```sh +php -S 127.0.0.1:8080 -t src/public src/public/index.php +``` + +## Beispiele + +Request speichern: + +```sh +curl -X POST http://127.0.0.1:8080/share \ + -H "Content-Type: application/json" \ + -H "X-Inbox-Secret: change-me" \ + -d '{"type":"url","content":"https://example.com"}' +``` + +Inbox lesen: + +```sh +curl "http://127.0.0.1:8080/inbox?limit=20" +``` + +## Tests + +Die Tests benötigen keine externen PHP-Abhängigkeiten und starten den eingebauten PHP-Server selbst. + +```sh +php tests/run.php +``` + +Zusätzlich gibt es weiter einen kleinen manuellen Smoke-Test für eine bereits laufende Instanz: + +```sh +chmod +x bin/smoke-test.sh +INBOX_SECRET=change-me bin/smoke-test.sh +``` + +## Hinweise + +- Laufzeitdaten liegen standardmäßig unter `var/inbox/` und damit nicht im Webroot. +- Fehlerdetails gehen ins PHP-Error-Log, nicht mehr an Clients. +- `sqlite` ist als optionaler Modus gedacht; Standard bleibt `ndjson`. diff --git a/bin/smoke-test.sh b/bin/smoke-test.sh new file mode 100755 index 0000000..4a8dcd1 --- /dev/null +++ b/bin/smoke-test.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +SERVICE_URL="${SERVICE_URL:-http://127.0.0.1:8080}" +INBOX_SECRET="${INBOX_SECRET:-change-me}" + +echo "POST ${SERVICE_URL}/share" +curl --fail --silent --show-error \ + -X POST "${SERVICE_URL}/share" \ + -H "Content-Type: application/json" \ + -H "X-Inbox-Secret: ${INBOX_SECRET}" \ + -d '{"type":"url","content":"https://example.com"}' + +echo +echo "GET ${SERVICE_URL}/inbox?limit=5" +curl --fail --silent --show-error \ + "${SERVICE_URL}/inbox?limit=5" + +echo diff --git a/src/public/.htaccess b/src/public/.htaccess new file mode 100644 index 0000000..f765de3 --- /dev/null +++ b/src/public/.htaccess @@ -0,0 +1,15 @@ +RewriteEngine On +RewriteBase / + +# -------------------------------------------------- +# CORS und Sicherheitsheader werden von index.php gesetzt. +# -------------------------------------------------- + +# -------------------------------------------------- +# Existierende Dateien/Ordner nicht umleiten +# -------------------------------------------------- +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d + +# Alles andere an index.php +RewriteRule ^ index.php [QSA,L] diff --git a/src/public/index.php b/src/public/index.php new file mode 100644 index 0000000..260cc45 --- /dev/null +++ b/src/public/index.php @@ -0,0 +1,487 @@ + true, + 'service' => 'share-inbox-service', + 'storageBackend' => $config['storage_backend'], + 'endpoints' => [ + 'POST /share', + 'GET /inbox?limit=50&offset=0', + ], + ], $config); + } + + jsonResponse(404, [ + 'ok' => false, + 'error' => 'Not found', + ], $config); +} catch (Throwable $e) { + logException($e); + jsonResponse(500, [ + 'ok' => false, + 'error' => 'Internal server error', + ], $config); +} + +function handleShare(array $config): void +{ + if ($config['inbox_secret'] !== '') { + $providedSecret = getHeader('X-Inbox-Secret'); + if (!hash_equals($config['inbox_secret'], $providedSecret ?? '')) { + jsonResponse(401, [ + 'ok' => false, + 'error' => 'Unauthorized', + ], $config); + } + } + + $contentTypeHeader = $_SERVER['CONTENT_TYPE'] ?? ($_SERVER['HTTP_CONTENT_TYPE'] ?? 'application/octet-stream'); + $contentType = normalizeContentType($contentTypeHeader); + $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null; + $ip = $_SERVER['REMOTE_ADDR'] ?? null; + + $rawBody = file_get_contents('php://input'); + if ($rawBody === false) { + jsonResponse(400, [ + 'ok' => false, + 'error' => 'Could not read request body', + ], $config); + } + + if ($rawBody === '') { + jsonResponse(400, [ + 'ok' => false, + 'error' => 'Request body is empty', + ], $config); + } + + if (strlen($rawBody) > $config['max_body_bytes']) { + jsonResponse(413, [ + 'ok' => false, + 'error' => 'Payload too large', + ], $config); + } + + $entry = [ + 'id' => generateId(), + 'receivedAt' => gmdate('c'), + 'ip' => $ip, + 'userAgent' => $userAgent, + 'contentType' => $contentType, + 'body' => parseIncomingBody($rawBody, $contentType), + ]; + + appendInboxEntry($config, $entry); + + jsonResponse(200, [ + 'ok' => true, + 'id' => $entry['id'], + 'receivedAt' => $entry['receivedAt'], + ], $config); +} + +function handleInbox(array $config): void +{ + $limit = isset($_GET['limit']) ? (int) $_GET['limit'] : DEFAULT_INBOX_LIMIT; + $offset = isset($_GET['offset']) ? (int) $_GET['offset'] : 0; + + $limit = max(1, min($limit, MAX_INBOX_LIMIT)); + $offset = max(0, $offset); + + $items = readInboxEntries($config); + $total = count($items); + $slice = array_slice($items, $offset, $limit); + + jsonResponse(200, [ + 'ok' => true, + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'items' => $slice, + ], $config); +} + +function appendInboxEntry(array $config, array $entry): void +{ + if ($config['storage_backend'] === 'sqlite') { + appendSqliteEntry($config, $entry); + return; + } + + appendNdjsonEntry($config['ndjson_file'], $entry); +} + +function readInboxEntries(array $config): array +{ + if ($config['storage_backend'] === 'sqlite') { + return readSqliteEntries($config); + } + + return readNdjsonEntries($config['ndjson_file']); +} + +function appendNdjsonEntry(string $file, array $entry): void +{ + $line = json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($line === false) { + throw new RuntimeException('Could not encode JSON'); + } + + $fh = fopen($file, 'ab'); + if ($fh === false) { + throw new RuntimeException('Could not open inbox file for writing'); + } + + try { + if (!flock($fh, LOCK_EX)) { + throw new RuntimeException('Could not lock inbox file'); + } + + if (fwrite($fh, $line . PHP_EOL) === false) { + throw new RuntimeException('Could not write inbox entry'); + } + + fflush($fh); + flock($fh, LOCK_UN); + } finally { + fclose($fh); + } +} + +function readNdjsonEntries(string $file): array +{ + if (!file_exists($file)) { + return []; + } + + $fh = fopen($file, 'rb'); + if ($fh === false) { + throw new RuntimeException('Could not open inbox file for reading'); + } + + $items = []; + + try { + while (($line = fgets($fh)) !== false) { + $line = trim($line); + if ($line === '') { + continue; + } + + $decoded = json_decode($line, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + $items[] = $decoded; + } + } + } finally { + fclose($fh); + } + + return array_reverse($items); +} + +function appendSqliteEntry(array $config, array $entry): void +{ + $db = openSqlite($config['sqlite_file']); + $statement = $db->prepare( + 'INSERT INTO inbox_entries (id, received_at, ip, user_agent, content_type, body_json) + VALUES (:id, :received_at, :ip, :user_agent, :content_type, :body_json)' + ); + + if ($statement === false) { + throw new RuntimeException('Could not prepare SQLite insert statement'); + } + + $bodyJson = json_encode($entry['body'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($bodyJson === false) { + throw new RuntimeException('Could not encode entry body for SQLite'); + } + + $ok = $statement->execute([ + ':id' => $entry['id'], + ':received_at' => $entry['receivedAt'], + ':ip' => $entry['ip'], + ':user_agent' => $entry['userAgent'], + ':content_type' => $entry['contentType'], + ':body_json' => $bodyJson, + ]); + + if ($ok === false) { + throw new RuntimeException('Could not write inbox entry to SQLite'); + } +} + +function readSqliteEntries(array $config): array +{ + $db = openSqlite($config['sqlite_file']); + $statement = $db->query( + 'SELECT id, received_at, ip, user_agent, content_type, body_json + FROM inbox_entries + ORDER BY received_at DESC, rowid DESC' + ); + + if ($statement === false) { + throw new RuntimeException('Could not read inbox entries from SQLite'); + } + + $items = []; + while (($row = $statement->fetch(PDO::FETCH_ASSOC)) !== false) { + $body = json_decode((string) $row['body_json'], true); + $items[] = [ + 'id' => $row['id'], + 'receivedAt' => $row['received_at'], + 'ip' => $row['ip'], + 'userAgent' => $row['user_agent'], + 'contentType' => $row['content_type'], + 'body' => json_last_error() === JSON_ERROR_NONE ? $body : null, + ]; + } + + return $items; +} + +function openSqlite(string $file): PDO +{ + $db = new PDO('sqlite:' . $file); + $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $db->exec( + 'CREATE TABLE IF NOT EXISTS inbox_entries ( + id TEXT PRIMARY KEY, + received_at TEXT NOT NULL, + ip TEXT NULL, + user_agent TEXT NULL, + content_type TEXT NOT NULL, + body_json TEXT NOT NULL + )' + ); + + return $db; +} + +function parseIncomingBody(string $rawBody, string $contentType): mixed +{ + if ($contentType === 'application/json') { + $decoded = json_decode($rawBody, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + + return [ + 'rawText' => $rawBody, + 'note' => 'invalid-json', + ]; + } + + if (str_starts_with($contentType, 'text/')) { + return [ + 'text' => $rawBody, + ]; + } + + if ($contentType === 'application/x-www-form-urlencoded') { + parse_str($rawBody, $data); + return $data; + } + + return [ + 'base64' => base64_encode($rawBody), + 'note' => 'binary-or-unknown-body', + ]; +} + +function buildConfig(): array +{ + $storageDir = normalizePath(envValue('INBOX_STORAGE_DIR', APP_ROOT . '/var/inbox')); + $backend = strtolower(trim(envValue('INBOX_STORAGE_BACKEND', 'ndjson'))); + if (!in_array($backend, ['ndjson', 'sqlite'], true)) { + $backend = 'ndjson'; + } + + $allowedOrigins = parseCsv(envValue('INBOX_CORS_ALLOW_ORIGINS', '*')); + + return [ + 'app_env' => envValue('APP_ENV', 'prod'), + 'inbox_secret' => envValue('INBOX_SECRET', ''), + 'max_body_bytes' => max(1, (int) envValue('INBOX_MAX_BODY_BYTES', (string) DEFAULT_MAX_BODY_BYTES)), + 'storage_backend' => $backend, + 'storage_dir' => $storageDir, + 'ndjson_file' => $storageDir . '/inbox.ndjson', + 'sqlite_file' => $storageDir . '/inbox.sqlite', + 'cors_allow_origins' => $allowedOrigins === [] ? ['*'] : $allowedOrigins, + ]; +} + +function ensureStorageReady(array $config): void +{ + if (!is_dir($config['storage_dir']) && !mkdir($config['storage_dir'], 0775, true) && !is_dir($config['storage_dir'])) { + throw new RuntimeException('Could not create storage directory'); + } +} + +function loadEnvFile(string $file): void +{ + if (!is_file($file) || !is_readable($file)) { + return; + } + + $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines === false) { + return; + } + + foreach ($lines as $line) { + $trimmed = trim($line); + if ($trimmed === '' || str_starts_with($trimmed, '#')) { + continue; + } + + $parts = explode('=', $trimmed, 2); + if (count($parts) !== 2) { + continue; + } + + $name = trim($parts[0]); + $value = trim($parts[1]); + $value = trim($value, "\"'"); + + if ($name !== '' && getenv($name) === false) { + putenv($name . '=' . $value); + $_ENV[$name] = $value; + $_SERVER[$name] = $value; + } + } +} + +function envValue(string $name, string $default): string +{ + $value = getenv($name); + if ($value === false) { + return $default; + } + + return $value; +} + +function parseCsv(string $value): array +{ + $parts = array_map('trim', explode(',', $value)); + return array_values(array_filter($parts, static fn (string $item): bool => $item !== '')); +} + +function normalizePath(string $path): string +{ + $trimmed = trim($path); + if ($trimmed === '') { + return APP_ROOT . '/var/inbox'; + } + + if ($trimmed[0] === '/') { + return rtrim($trimmed, '/'); + } + + return rtrim(APP_ROOT . '/' . ltrim($trimmed, '/'), '/'); +} + +function normalizeContentType(string $contentType): string +{ + return strtolower(trim(explode(';', $contentType)[0])); +} + +function getHeader(string $name): ?string +{ + $key = 'HTTP_' . strtoupper(str_replace('-', '_', $name)); + return $_SERVER[$key] ?? null; +} + +function generateId(): string +{ + return bin2hex(random_bytes(8)); +} + +function sendCorsHeaders(array $config): void +{ + $requestOrigin = $_SERVER['HTTP_ORIGIN'] ?? ''; + $allowedOrigin = '*'; + + if ($config['cors_allow_origins'] !== ['*']) { + $allowedOrigin = in_array($requestOrigin, $config['cors_allow_origins'], true) + ? $requestOrigin + : $config['cors_allow_origins'][0]; + } + + header('Access-Control-Allow-Origin: ' . $allowedOrigin); + header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); + header('Access-Control-Allow-Headers: Content-Type, X-Inbox-Secret, Authorization'); + header('Access-Control-Max-Age: 86400'); + header('X-Content-Type-Options: nosniff'); + header('X-Frame-Options: DENY'); + header('Referrer-Policy: no-referrer'); + header('Permissions-Policy: geolocation=(), microphone=(), camera=()'); +} + +function jsonResponse(int $statusCode, array $payload, array $config): void +{ + http_response_code($statusCode); + sendCorsHeaders($config); + header('Content-Type: application/json; charset=utf-8'); + + $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + if ($json === false) { + $json = "{\"ok\":false,\"error\":\"Response encoding failed\"}"; + } + + echo $json; + exit; +} + +function logException(Throwable $e): void +{ + error_log(sprintf( + '[share-inbox-service] %s in %s:%d', + $e->getMessage(), + $e->getFile(), + $e->getLine() + )); +} diff --git a/tests/run.php b/tests/run.php new file mode 100644 index 0000000..528d6bd --- /dev/null +++ b/tests/run.php @@ -0,0 +1,324 @@ + 'test', + 'INBOX_SECRET' => 'test-secret', + 'INBOX_STORAGE_BACKEND' => $backend, + 'INBOX_STORAGE_DIR' => $storageDir, + 'INBOX_CORS_ALLOW_ORIGINS' => '*', + ]; + + $server = startServer($env); + + try { + testRootEndpoint($server['baseUrl'], $backend); + testUnauthorizedShare($server['baseUrl']); + testEmptyBodyRejected($server['baseUrl']); + testOptionsRequest($server['baseUrl']); + testShareAndInboxFlow($server['baseUrl'], $backend); + } finally { + stopServer($server['process']); + resetDirectory($storageDir); + } +} + +function testRootEndpoint(string $baseUrl, string $backend): void +{ + $response = request('GET', $baseUrl . '/'); + assertStatus(200, $response, 'root endpoint returns 200'); + + $body = decodeJson($response); + assertTrue(($body['ok'] ?? false) === true, 'root endpoint returns ok=true'); + assertSame('share-inbox-service', $body['service'] ?? null, 'root endpoint returns service name'); + assertSame($backend, $body['storageBackend'] ?? null, 'root endpoint reports active backend'); +} + +function testUnauthorizedShare(string $baseUrl): void +{ + $response = request( + 'POST', + $baseUrl . '/share', + ['Content-Type: application/json'], + '{"type":"url","content":"https://example.com/unauthorized"}' + ); + + assertStatus(401, $response, 'missing secret returns 401'); + $body = decodeJson($response); + assertSame('Unauthorized', $body['error'] ?? null, 'missing secret returns unauthorized error'); +} + +function testEmptyBodyRejected(string $baseUrl): void +{ + $response = request( + 'POST', + $baseUrl . '/share', + [ + 'Content-Type: application/json', + 'X-Inbox-Secret: test-secret', + ], + '' + ); + + assertStatus(400, $response, 'empty body returns 400'); + $body = decodeJson($response); + assertSame('Request body is empty', $body['error'] ?? null, 'empty body returns clear message'); +} + +function testOptionsRequest(string $baseUrl): void +{ + $response = request('OPTIONS', $baseUrl . '/share'); + + assertStatus(200, $response, 'options request returns 200'); + assertSame('*', headerValue($response['headers'], 'Access-Control-Allow-Origin'), 'options returns CORS origin'); + assertSame('GET, POST, OPTIONS', headerValue($response['headers'], 'Access-Control-Allow-Methods'), 'options returns allowed methods'); +} + +function testShareAndInboxFlow(string $baseUrl, string $backend): void +{ + $jsonResponse = request( + 'POST', + $baseUrl . '/share', + [ + 'Content-Type: application/json', + 'X-Inbox-Secret: test-secret', + ], + '{"type":"url","content":"https://example.com/' . $backend . '-json"}' + ); + + assertStatus(200, $jsonResponse, 'json share returns 200'); + $jsonBody = decodeJson($jsonResponse); + assertTrue(isset($jsonBody['id']) && $jsonBody['id'] !== '', 'json share returns id'); + + usleep(200000); + + $textResponse = request( + 'POST', + $baseUrl . '/share', + [ + 'Content-Type: text/plain; charset=utf-8', + 'X-Inbox-Secret: test-secret', + ], + 'hello ' . $backend + ); + + assertStatus(200, $textResponse, 'text share returns 200'); + + $inboxResponse = request('GET', $baseUrl . '/inbox?limit=10'); + assertStatus(200, $inboxResponse, 'inbox returns 200'); + + $inbox = decodeJson($inboxResponse); + assertSame(2, $inbox['total'] ?? null, 'inbox total matches stored entries'); + assertSame(2, count($inbox['items'] ?? []), 'inbox returns two items'); + + $latest = $inbox['items'][0] ?? []; + $older = $inbox['items'][1] ?? []; + + assertSame('text/plain', $latest['contentType'] ?? null, 'latest item preserves normalized text content type'); + assertSame('hello ' . $backend, $latest['body']['text'] ?? null, 'latest item stores text body'); + assertSame('application/json', $older['contentType'] ?? null, 'older item preserves json content type'); + assertSame('https://example.com/' . $backend . '-json', $older['body']['content'] ?? null, 'older item stores json body'); +} + +function startServer(array $env): array +{ + $port = findFreePort(); + $command = escapeshellarg(PHP_BINARY) + . ' -S 127.0.0.1:' . $port + . ' -t ' . escapeshellarg(PUBLIC_DIR) + . ' ' . escapeshellarg(PUBLIC_DIR . '/index.php'); + + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open($command, $descriptors, $pipes, PROJECT_ROOT, array_merge($_ENV, $env)); + if (!is_resource($process)) { + throw new RuntimeException('Could not start PHP test server'); + } + + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $baseUrl = 'http://127.0.0.1:' . $port; + $deadline = microtime(true) + 5.0; + + while (microtime(true) < $deadline) { + $status = proc_get_status($process); + if (!$status['running']) { + $stderr = stream_get_contents($pipes[2]); + throw new RuntimeException('PHP test server exited early: ' . trim((string) $stderr)); + } + + $response = @file_get_contents($baseUrl . '/'); + if ($response !== false) { + fclose($pipes[1]); + fclose($pipes[2]); + return [ + 'process' => $process, + 'baseUrl' => $baseUrl, + ]; + } + + usleep(100000); + } + + stopServer($process); + throw new RuntimeException('PHP test server did not become ready in time'); +} + +function stopServer($process): void +{ + if (!is_resource($process)) { + return; + } + + $status = proc_get_status($process); + if ($status['running']) { + proc_terminate($process); + usleep(200000); + + $status = proc_get_status($process); + if ($status['running']) { + proc_terminate($process, 9); + } + } + + proc_close($process); +} + +function findFreePort(): int +{ + for ($port = 18080; $port < 18120; $port++) { + $socket = @stream_socket_server('tcp://127.0.0.1:' . $port, $errno, $errstr); + if ($socket !== false) { + fclose($socket); + return $port; + } + } + + throw new RuntimeException('Could not find free localhost port for tests'); +} + +function request(string $method, string $url, array $headers = [], string $body = ''): array +{ + $context = stream_context_create([ + 'http' => [ + 'method' => $method, + 'header' => implode("\r\n", $headers), + 'content' => $body, + 'ignore_errors' => true, + 'timeout' => 5, + ], + ]); + + $responseBody = file_get_contents($url, false, $context); + $responseHeaders = $http_response_header ?? []; + + if ($responseBody === false && $responseHeaders === []) { + throw new RuntimeException('HTTP request failed: ' . $method . ' ' . $url); + } + + return [ + 'status' => extractStatusCode($responseHeaders), + 'headers' => $responseHeaders, + 'body' => $responseBody === false ? '' : $responseBody, + ]; +} + +function extractStatusCode(array $headers): int +{ + if ($headers === []) { + throw new RuntimeException('Missing HTTP response headers'); + } + + if (!preg_match('/\s(\d{3})\s/', $headers[0], $matches)) { + throw new RuntimeException('Could not parse HTTP status from response'); + } + + return (int) $matches[1]; +} + +function decodeJson(array $response): array +{ + $decoded = json_decode($response['body'], true); + if (!is_array($decoded)) { + throw new RuntimeException('Expected JSON object response, got: ' . $response['body']); + } + + return $decoded; +} + +function headerValue(array $headers, string $name): ?string +{ + $prefix = strtolower($name) . ':'; + + foreach ($headers as $header) { + if (str_starts_with(strtolower($header), $prefix)) { + return trim(substr($header, strlen($prefix))); + } + } + + return null; +} + +function resetDirectory(string $path): void +{ + if (!is_dir($path)) { + mkdir($path, 0775, true); + return; + } + + $items = scandir($path); + if ($items === false) { + throw new RuntimeException('Could not read test directory: ' . $path); + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $file = $path . '/' . $item; + if (is_file($file) && !unlink($file)) { + throw new RuntimeException('Could not remove test file: ' . $file); + } + } +} + +function assertStatus(int $expected, array $response, string $message): void +{ + assertSame($expected, $response['status'], $message . ' (response body: ' . $response['body'] . ')'); +} + +function assertSame(mixed $expected, mixed $actual, string $message): void +{ + if ($expected !== $actual) { + throw new RuntimeException( + $message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true) + ); + } +} + +function assertTrue(bool $condition, string $message): void +{ + if (!$condition) { + throw new RuntimeException($message); + } +} diff --git a/var/inbox/.gitignore b/var/inbox/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/var/inbox/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore