Devlog · Phase 0 · 3/7
E-Mail-Adressen sammeln ohne eine einzige Tabelle
aubia.dev sammelt E-Mail-Adressen ohne Datenbank. Der Inhalt wird in flachen Dateien gespeichert, und jede E-Mail-Adresse wird über einen signierten Proxy an aubia.cloud gesendet.
- architecture
- security
- waitlist
- gdpr

Die Warteliste von aubia.dev sammelt E-Mail-Adressen, und das Repo, das sie bedient, enthält keine einzige Tabelle. Kein Model, keine Migration: Den Migrationsordner gibt es nicht einmal.
Einer Website die Datenbank zu nehmen, nimmt ihr auf einen Schlag eine ganze Klasse von Sorgen: Backups, Verschlüsselung im Ruhezustand, Datenleck, DSGVO-Löschung. Man sichert nicht ab, was man nicht besitzt.
Jede abgesendete E-Mail-Adresse durchquert den Speicher eines Workers für die Dauer eines ausgehenden HTTP-Aufrufs, dann verschwindet sie. Der Dienst, der sie empfängt, bestätigt und aufbewahrt, ist aubia.cloud.
Personenbezogene Daten gehen nur über einen einzigen Fluss von der Website in die Cloud. Ein signierter Proxy reicht die Übermittlung an den entfernten Dienst weiter und legt ihr einen vor Ort berechneten Herkunftsnachweis bei.
Flache Dateien für den gesamten Inhalt
Die Texte der Website sind in JSON-Übersetzungsdateien gespeichert, die Artikel dieses Blogs in Markdown-Dateien, die mit dem Code versioniert sind. Diese flachen Dateien werden direkt von der Platte gelesen, ohne Datenbankserver davor.
Sechs Locales und keine einzige SQL-Abfrage. Der Inhalt wird mit dem Repo deployt, ohne Migrationsschritt.
Redis bleibt an Bord, für die Session, einen flüchtigen Cache und den Rate Limiter. Dieser deckelt die Zahl der von einer Herkunft akzeptierten Übermittlungen auf einem Zeitfenster. Er arbeitet auf einem kurzlebigen Hash der E-Mail-Adresse und einem weiteren der IP-Adresse, die mit dem Fenster verfallen, dem sie dienen.
Der Cache kennt eine Ausnahme: Der Blog-Index wird ohne Ablaufdatum darin abgelegt. Er enthält Titel, Daten und das bereits gerenderte HTML öffentlicher Artikel, keine personenbezogenen Daten.
Das Diagramm vereinfacht ein wenig: Andere Flüsse überqueren die Grenze, etwa das öffentliche Lesen des Zählers der Bestätigten. Der signierte Proxy ist der einzige, der personenbezogene Daten transportiert.
Ein Controller ohne Schreibzugriff
Wenn Sie Ihre E-Mail-Adresse absenden, bleibt der Controller schlank. Er validiert, legt die Felder in ein typisiertes Objekt, delegiert an eine Action und gibt eine Flash-Nachricht zurück.
Er kennt weder das Netzwerk noch die Signatur, und er hat keine Datenbank, in die er schreiben könnte. Die Methode store() tut nichts anderes:
final class WaitlistController
{
public function store(SubmitWaitlistRequest $request, SubmitWaitlistEmail $action): RedirectResponse
{
$validated = $request->validated();
$data = WaitlistSubmissionData::fromArray([
'email' => $validated['email'],
'utm_source' => $validated['utm_source'] ?? null,
'utm_medium' => $validated['utm_medium'] ?? null,
'utm_campaign' => $validated['utm_campaign'] ?? null,
'rendered_at' => (int) $validated['rendered_at'],
'locale' => $validated['locale'],
]);
$status = $action->execute($data);
if ($status->isSuccess()) {
return back()->with('waitlist_success', $status->messageKey());
}
return back()->with('waitlist_error', $status->messageKey());
}
}
Die E-Mail-Adresse existiert nur im Speicher, für die Dauer des Requests. Auf Seiten der Website wird keine Zeile geschrieben.
Das geteilte Secret und die Bot-Fallen
Die Action SubmitWaitlistEmail übernimmt. Sie serialisiert die zu übertragenden Felder und berechnet dann eine HMAC-SHA-256-Signatur des so entstandenen Körpers, also eine Signatur mit geteiltem Schlüssel.
Die Signatur des gesendeten Körpers
Sie postet an aubia.cloud, mit einem per Vertrag festgelegten Origin-Header. Der serialisierte Körper, das Secret und die Header werden in dieser Reihenfolge gebaut:
$rawBody = json_encode($data->toCloudPayload(), JSON_THROW_ON_ERROR);
$secret = (string) config('services.cloud.signing_key');
$headers = [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'Origin' => 'https://aubia.dev',
'X-Signature' => 'sha256=' . hash_hmac('sha256', $rawBody, $secret),
];
Die Signatur wird auf genau dem Körper berechnet, der abgeht, mit einem Secret, das beide Seiten kennen. Die Cloud spielt dieselbe Berechnung nach und vergleicht.
Das Secret verlässt diese beiden Server nie. Ein Bearer Token dagegen reist bei jedem Aufruf mit, und er kann in die Logs gelangen, wenn er dort nicht maskiert wird.
Lokal lässt sich die Signatur über ein Konfigurations-Flag abschalten, um das Formular ohne die ganze Secret-Kette zu testen. In der Produktion schlägt der Versand fehl, wenn die Signatur deaktiviert oder der Schlüssel abwesend ist, statt stillschweigend im Klartext zu posten.
Zwei Fallen und ein ausgeschlossenes Feld
Das Formular hält Bots mit zwei Fallen auf, denen ein Besucher nie begegnet: einem Honeypot und einer Mindestwartezeit. Der Artikel über die Voranmeldung führt sie aus.
Eine der beiden berührt den ausgehenden Vertrag. Das Zeitstempelfeld rendered_at dient der lokalen Validierung und erreicht die Cloud nie, was die Oberfläche des Vertrags minimal hält und die Signatur stabilisiert.
Die Ausgabemethode des DTO sagt es in ihrem Kommentar und wendet es in ihrem Array an:
public function toCloudPayload(): array
{
// rendered_at (piège temporel) volontairement exclu.
return [
'email' => $this->email,
'utm_source' => $this->utmSource,
'utm_medium' => $this->utmMedium,
'utm_campaign' => $this->utmCampaign,
'locale' => $this->locale,
];
}
Der Zeitstempel ist das einzige Feld, das das DTO trägt, ohne es an die Cloud weiterzugeben.
Diese beiden Fallen bewachen das Formular, und das Formular allein. Eine Mindestwartezeit umgeht ein sorgfältiger Bot, der eine Pause programmieren kann, und einen Honeypot ein Automat, der das Stylesheet liest.
Der ausgehende Vertrag wird anders bewacht, durch das geteilte Secret. Ohne es wird keine von außen geschmiedete Anfrage akzeptiert.
Eine Anfrage wiederholen, ohne zweimal anzumelden
Ein naiver HTTP-Client wiederholt jeden fehlgeschlagenen Versuch. Die Politik des ausgehenden Clients hängt davon ab, was die Website über das Schicksal der Anfrage weiß.
Eine eingetroffene Antwort, auch ein Serverfehler, beweist, dass die Cloud die Anfrage empfangen hat. Was sie damit gemacht hat, bleibt unbekannt, und der Anmelde-POST wird in diesem Fall deshalb nie wiederholt.
Ein Netzwerkfehler dagegen bringt gar keine Antwort zurück. Die Anfrage hat die Cloud wahrscheinlich nie erreicht, und das ist der einzige Fall, in dem sie wiederholt wird.
Der ausgehende Client entscheidet deshalb Anfrage für Anfrage. Zwei Ausführungen sind manchmal genau so viel wert wie eine einzige.
Der GET des Zählers der Bestätigten wird in allen Fällen erneut versucht, auch auf einen Serverfehler, denn eine Zahl noch einmal zu lesen meldet niemanden an. Der Anmelde-POST wird nur auf einen Netzwerkfehler wiederholt, und die Bedingung steht im Client:
->retry(
$retries + 1,
fn(int $attempt): int => $backoffMs + random_int(0, 100),
// Retry UNIQUEMENT sur erreur réseau : aucune réponse reçue, et le cloud
// absorbe un doublon éventuel. Jamais sur une réponse reçue, même 5xx.
fn(Throwable $e, PendingRequest $request): bool => $e instanceof ConnectionException,
throw: false,
)
Nur eine ConnectionException löst einen neuen Versuch aus. Hat die Cloud geantwortet, und sei es mit einem Serverfehler, mappt die Website die Antwort auf einen fachlichen Status und zeigt die entsprechende Nachricht an.
Die Einwilligung auf Cloud-Seite
aubia.cloud erzeugt den Bestätigungstoken, versendet die E-Mail, lässt den Link nach 48 Stunden ablaufen und bewahrt den Nachweis der Einwilligung auf.
Solange eine Anmeldung nicht bestätigt ist, geht keine weitere Kommunikation raus. Die nie bestätigten E-Mail-Adressen werden nach 30 Tagen gelöscht, der Frist, die die Datenschutzseite veröffentlicht.
Der Server der Website sieht diesen Token nie. Die Bestätigungs- und die Abmeldeseite lassen ihn durch den Browser laufen, im Direktaufruf zur Cloud, ohne über ihn zurückzukehren. Der Rest des Wegs, vom E-Mail-Feld bis zum Zähler der Bestätigten, ist in einem anderen Artikel erzählt.
Drei Cookies und ein Theme-Schlüssel
Alles, was der Server ausliefert, ist für alle Besucher identisch, mit einer Ausnahme: der Wurzel. / leitet nach /fr, /en oder eine andere der sechs Locales weiter, nach einem Präferenz-Cookie und dann dem Accept-Language-Header.
Dieser Cookie, aubia_locale, trägt einen Locale-Code, sonst nichts. Zwei weitere begleiten ihn: XSRF-TOKEN für das CSRF-Token, das vor Übermittlungen schützt, die von einer anderen Site aus geschmiedet werden, und aubia-session für die Laravel-Session.
Die Theme-Präferenz ist im localStorage gespeichert, unter dem Schlüssel aubia-theme. Der localStorage ist der Platz, den der Browser einer einzelnen Site vorbehält, und sein Inhalt reist in keiner Anfrage mit.
Diese drei Cookies sind technisch, keiner misst die Reichweite und keiner dient der Werbung. Von der Einwilligung befreit, ersparen sie der Website ein Banner.
Der Preis und der Gewinn der Abwesenheit
Ein geteiltes Secret ist in zwei Repos deklariert, mit seiner zu orchestrierenden Rotation. Jede Anmeldung hängt am Netzwerk. Antwortet aubia.cloud nicht, sagt das Formular es, statt irgendwohin zu schreiben und auf bessere Tage zu warten.
Der Zähler der Bestätigten hat keine Tabelle abzufragen. Er ruft den öffentlichen Lese-Endpunkt der Cloud auf, hält das Ergebnis einige Dutzend Sekunden im Cache und bewahrt einen letzten bekannten Wert als Ausfallreserve.
Redis bleibt für Session, Cache und Rate Limiter erforderlich. Ein wenig Zustand bleibt, kurzlebig.
Dem gegenüber wird beim Deployment keine Migration ausgeführt. Die Website hat kein Backup zu testen, keine Verschlüsselung im Ruhezustand zu auditieren, keine E-Mail-Adresse zu löschen. Die 48 Stunden des Tokens und die 30 Tage Aufbewahrung werden auf Cloud-Seite gezählt, am einzigen Ort, der etwas besitzt.
Die Grenzen der Abwägung
Ich entscheide zwischen diesen beiden Listen: eine Netzwerkabhängigkeit und ein zu rotierender Schlüssel auf der einen Seite, vier Compliance-Baustellen, die es auf der anderen gar nicht gibt.
Diese Abwägung überträgt sich nicht auf eine Anwendung, die ihre Nutzer bedienen muss, wenn das Netz ausfällt. Sie hielte auch nicht auf einer Website, die so weit wüchse, dass sie mehr als öffentlichen Inhalt besäße.
In ihrem heutigen Umfang braucht die Website keine einzige Tabelle.
Dieses Projekt wird hier erzählt, Artikel für Artikel. Was als Nächstes kommt, hängt davon ab, was Sie dazu sagen.
Auf die Warteliste