CluPilotCloud/app/Services/Terminal/TicketStore.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);
}
}