Initial commit
This commit is contained in:
commit
723d6d3d61
6
.env.example
Normal file
6
.env.example
Normal 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
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
.env
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
/var/inbox/*
|
||||
!/var/inbox/.gitignore
|
||||
/var/test-*/
|
||||
|
||||
*.log
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
81
README.md
Normal 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
19
bin/smoke-test.sh
Executable 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
15
src/public/.htaccess
Normal 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
487
src/public/index.php
Normal 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
324
tests/run.php
Normal 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
2
var/inbox/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
Loading…
x
Reference in New Issue
Block a user