94 lines
3.9 KiB
PHP
94 lines
3.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Terminal;
|
|
|
|
use Illuminate\Support\Facades\Redis;
|
|
|
|
/**
|
|
* Wo eine Eintrittskarte liegt, wie lange, und warum genau so.
|
|
*
|
|
* Herausgezogen, als ein zweiter Ausstellungsweg dazukam (das Terminal zum
|
|
* CluPilot-Server selbst). Was hier steht, ist die Verabredung mit einem
|
|
* Container, der dieses Repo nicht kennt — sie zweimal hinzuschreiben hieße,
|
|
* darauf zu wetten, dass beide Abschriften gemeinsam altern. Wer die
|
|
* Ausstellung ändert, ändert sie hier einmal für beide.
|
|
*
|
|
* ROHES REDIS, NICHT DIE Cache-FASSADE — zwei Gründe, beide gemessen:
|
|
*
|
|
* 1. `Cache::put()` läuft über `Illuminate\Cache\RedisStore`, und die
|
|
* serialisiert jeden Wert mit PHP `serialize()`, solange kein `serializer`
|
|
* in `config/database.php` gesetzt ist (hier: keiner). Aus dem JSON unten
|
|
* würde in Redis ein `s:412:"{...}";` — eine PHP-Hülle, die der
|
|
* Python-Container nicht kennt und nicht raten kann.
|
|
* 2. `Cache::pull()` ist `get()` dann `forget()`, zwei getrennte Runden. Zwei
|
|
* gleichzeitige Einlösungen bekämen beide den Root-Schlüssel, bevor die
|
|
* zweite merkt, dass er weg ist. Redis' `GETDEL` ist eine einzige, atomare
|
|
* Runde: die zweite Einlösung sieht den fehlenden Schlüssel, statt ihn noch
|
|
* zu bekommen.
|
|
*
|
|
* DER SCHLÜSSEL, VOLLSTÄNDIG — für den Container, der dagegen schreibt:
|
|
* `terminal:ticket:<64 Hex-Zeichen>`, auf der `cache`-Redis-Verbindung
|
|
* (`config('database.redis.cache')`, standardmäßig Datenbank 1). phpredis legt
|
|
* darüber transparent noch `REDIS_PREFIX`
|
|
* (`config('database.redis.options.prefix')`, auf dieser Installation
|
|
* `clupilot-database-`, aus `APP_NAME` abgeleitet) — unsichtbar für jeden
|
|
* PHP-Aufruf über diese Verbindung, aber Teil des tatsächlichen Schlüssels für
|
|
* jeden Client, der nicht über phpredis mit derselben Option spricht. Der
|
|
* Container muss also denselben Wert voranstellen; er kann ihn aus dieser
|
|
* Klasse allein nicht erraten, deshalb steht er hier.
|
|
*/
|
|
final class TicketStore
|
|
{
|
|
/**
|
|
* Dreißig Sekunden, weil ein Ticket nur den Weg vom Klick zum offenen
|
|
* Fenster überbrücken muss. Wer die Seite offen liegen lässt und später neu
|
|
* lädt, bekommt ein frisches.
|
|
*/
|
|
public const TTL_SECONDS = 30;
|
|
|
|
private const PREFIX = 'terminal:ticket:';
|
|
|
|
/**
|
|
* Legt eine Sitzung ab und gibt den Schlüssel zurück, den der Browser
|
|
* bekommt: zweiunddreißig Byte Zufall, sonst nichts.
|
|
*
|
|
* Alles, was die Brücke wirklich braucht — Adresse, Benutzer, privater
|
|
* Schlüssel, gepinnter Fingerabdruck — liegt serverseitig daneben und wird
|
|
* vom Container gelesen, nie vom Browser mitgegeben. Ein Ticket, das die
|
|
* Verbindungsdaten selbst trüge, stünde in der Adresszeile, im Verlauf und
|
|
* in jedem Protokoll dazwischen.
|
|
*
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
public static function issue(array $payload): string
|
|
{
|
|
$ticket = bin2hex(random_bytes(32));
|
|
|
|
Redis::connection('cache')->setex(
|
|
self::PREFIX.$ticket,
|
|
self::TTL_SECONDS,
|
|
json_encode($payload, JSON_THROW_ON_ERROR),
|
|
);
|
|
|
|
return $ticket;
|
|
}
|
|
|
|
/**
|
|
* Liest das Ticket und löscht es im selben Zug — mit Redis' eigenem
|
|
* `GETDEL`, nicht mit zwei Aufrufen. Erst das macht „genau eine Einlösung"
|
|
* zu einer Zusage, die auch unter zwei gleichzeitigen Versuchen hält.
|
|
*
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public static function redeem(string $ticket): ?array
|
|
{
|
|
$raw = Redis::connection('cache')->getdel(self::PREFIX.$ticket);
|
|
|
|
// phpredis meldet ein fehlendes/abgelaufenes/schon geholtes Ticket als
|
|
// `false`, nicht als `null` — die Cache-Fassade glättete das vorher.
|
|
return $raw === false || $raw === null
|
|
? null
|
|
: json_decode((string) $raw, true, flags: JSON_THROW_ON_ERROR);
|
|
}
|
|
}
|