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