Initial commit

This commit is contained in:
RackNar 2026-03-17 15:47:32 +01:00
commit 723d6d3d61
9 changed files with 964 additions and 0 deletions

6
.env.example Normal file
View File

@ -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=*

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.env
.DS_Store
Thumbs.db
/var/inbox/*
!/var/inbox/.gitignore
/var/test-*/
*.log

21
LICENSE Normal file
View File

@ -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.

81
README.md Normal file
View File

@ -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`.

19
bin/smoke-test.sh Executable file
View File

@ -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

15
src/public/.htaccess Normal file
View File

@ -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]

487
src/public/index.php Normal file
View File

@ -0,0 +1,487 @@
<?php
declare(strict_types=1);
ini_set('display_errors', '0');
const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024;
const DEFAULT_INBOX_LIMIT = 50;
const MAX_INBOX_LIMIT = 500;
define('APP_ROOT', dirname(__DIR__, 2));
loadEnvFile(APP_ROOT . '/.env');
$config = buildConfig();
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
sendCorsHeaders($config);
http_response_code(200);
exit;
}
ensureStorageReady($config);
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$scriptName = $_SERVER['SCRIPT_NAME'] ?? '';
if ($scriptName !== '' && str_starts_with($uri, $scriptName)) {
$uri = substr($uri, strlen($scriptName));
$uri = $uri === '' ? '/' : $uri;
}
try {
if ($method === 'POST' && $uri === '/share') {
handleShare($config);
exit;
}
if ($method === 'GET' && $uri === '/inbox') {
handleInbox($config);
exit;
}
if ($method === 'GET' && ($uri === '/' || $uri === '')) {
jsonResponse(200, [
'ok' => 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()
));
}

324
tests/run.php Normal file
View File

@ -0,0 +1,324 @@
<?php
declare(strict_types=1);
const PROJECT_ROOT = __DIR__ . '/..';
const PUBLIC_DIR = PROJECT_ROOT . '/src/public';
runSuite('ndjson');
runSuite('sqlite');
echo "All tests passed.\n";
function runSuite(string $backend): void
{
$storageDir = PROJECT_ROOT . '/var/test-' . $backend;
resetDirectory($storageDir);
$env = [
'APP_ENV' => '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);
}
}

2
var/inbox/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore