Diario di bordo · Fase 0 · 3/7
Raccogliere indirizzi email senza una sola tabella
aubia.dev raccoglie indirizzi email senza database. Il contenuto è archiviato in file flat, e ogni indirizzo email viene inviato verso aubia.cloud tramite un proxy firmato.
- architecture
- security
- waitlist
- gdpr

La lista d'attesa di aubia.dev raccoglie indirizzi email, e il repo che la serve non contiene una sola tabella. Non un model, non una migration: la cartella delle migration nemmeno esiste.
Togliere il database da un sito vetrina toglie in un colpo un'intera classe di preoccupazioni: backup, cifratura a riposo, fuga di dati, purga GDPR. Non si mette in sicurezza ciò che non si detiene.
Ogni indirizzo email inviato attraversa la memoria di un worker per la durata di una chiamata HTTP in uscita, poi sparisce. Il servizio che lo riceve, lo conferma e lo conserva è aubia.cloud.
Un dato personale passa dal sito al cloud per un solo flusso. Un proxy firmato trasmette l'invio al servizio remoto, allegandovi una prova di origine calcolata sul posto.
File flat per tutto il contenuto
I testi del sito sono archiviati in file di traduzione JSON, gli articoli di questo blog in Markdown versionati con il codice. Questi file flat sono letti direttamente dal disco, senza un server di database davanti.
Sei locale e nessuna query SQL. Il contenuto si distribuisce con il repo, senza passaggio di migrazione.
Redis resta al suo posto, per la sessione, una cache transitoria e il rate limiter. Quest'ultimo mette un tetto al numero di invii accettati da una stessa origine su una finestra di tempo. Lavora su un hash effimero dell'indirizzo email e su un altro dell'indirizzo IP, che scadono con la finestra che servono.
La cache ammette un'eccezione: l'indice del blog vi è scritto senza data di scadenza. Contiene titoli, date e l'HTML già reso di articoli pubblici, nessun dato personale.
Il diagramma semplifica un po': altri flussi attraversano il confine, come la lettura pubblica del contatore dei confermati. Il proxy firmato è l'unico che trasporta un dato personale.
Un controller senza scrittura
Quando invia il suo indirizzo email, il controller resta sottile. Valida, ripone i campi in un oggetto tipizzato, delega a una Action e restituisce un messaggio flash.
Non conosce né la rete né la firma, e non ha alcun database dove scrivere. Il metodo store() non fa altro:
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());
}
}
L'indirizzo email esiste solo in memoria, per la durata della richiesta. Nessuna riga viene scritta lato sito.
Il segreto condiviso e le trappole anti-robot
L'Action SubmitWaitlistEmail prende il testimone. Serializza i campi da trasmettere, poi calcola una firma HMAC SHA-256 del corpo ottenuto, ossia una firma a chiave condivisa.
La firma del corpo inviato
Posta verso aubia.cloud, con un header Origin fissato da contratto. Il corpo serializzato, il segreto e gli header si costruiscono in quest'ordine:
$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),
];
La firma è calcolata sul corpo esatto che parte, con un segreto che i due lati conoscono. Il cloud rigioca lo stesso calcolo e confronta.
Il segreto non lascia mai quei due server. Un bearer token, invece, viaggia in ogni chiamata, e può trapelare nei log se non vi viene oscurato.
In locale la firma si disattiva con un flag di configurazione, per provare il form senza montare tutta la catena del segreto. In produzione l'invio fallisce se la firma è disattivata o la chiave assente, invece di postare in chiaro e in silenzio.
Due trappole e un campo escluso
Il form ferma i robot con due trappole che un visitatore non incontra mai: un honeypot e un ritardo minimo. L'articolo sulla preiscrizione le descrive in dettaglio.
Una delle due tocca il contratto in uscita. Il campo di marcatura temporale rendered_at serve alla validazione locale e non raggiunge mai il cloud, il che tiene minima la superficie del contratto e stabilizza la firma.
Il metodo di uscita del DTO lo dice nel suo commento e lo applica nel suo array:
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,
];
}
La marcatura temporale è l'unico campo che il DTO trasporta senza trasmetterlo al cloud.
Queste due trappole custodiscono il form, e il form soltanto. Un ritardo minimo si aggira con un robot accurato, capace di programmare un'attesa, e un honeypot con un automa che legge il foglio di stile.
Il contratto in uscita si custodisce altrimenti, con il segreto condiviso. Senza di esso, nessuna richiesta forgiata dall'esterno viene accettata.
Rigiocare una richiesta senza iscrivere due volte
Un client HTTP ingenuo rigioca ogni tentativo fallito. La politica del client in uscita dipende da ciò che il sito sa dell'esito della richiesta.
Una risposta ricevuta, anche un errore server, prova che il cloud ha ricevuto la richiesta. Cosa ne abbia fatto resta ignoto, e il POST di iscrizione non si rigioca mai in questo caso.
Un guasto di rete, invece, non riporta alcuna risposta. La richiesta con ogni probabilità non ha raggiunto il cloud, ed è il solo caso in cui viene rigiocata.
Il client in uscita decide quindi richiesta per richiesta. Due esecuzioni valgono a volte esattamente quanto una sola.
Il GET del contatore dei confermati si ritenta in tutti i casi, errore server compreso, perché rileggere un numero non iscrive nessuno. Il POST di iscrizione si rigioca solo su un errore di rete, e la condizione è scritta nel 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,
)
Solo una ConnectionException innesca un nuovo tentativo. Se il cloud ha risposto, fosse anche con un errore server, il sito mappa la risposta su uno stato di dominio e mostra il messaggio corrispondente.
Il consenso lato cloud
aubia.cloud genera il token di conferma, invia l'email, fa scadere il link dopo 48 ore e conserva la traccia del consenso.
Finché un'iscrizione non è confermata, non parte alcun'altra comunicazione. Gli indirizzi email mai confermati vengono purgati dopo 30 giorni, la durata che la pagina sulla privacy pubblica.
Il server del sito non vede mai quel token. Le pagine di conferma e di cancellazione lo fanno transitare dal browser, in chiamata diretta verso il cloud, senza ripassare da lui. Il resto del percorso, dal campo email al contatore dei confermati, è raccontato in un altro articolo.
Tre cookie e una chiave di tema
Tutto ciò che il server rende è identico per ogni visitatore, con un'eccezione: la radice. / reindirizza verso /fr, /en o un'altra delle sei locale, secondo un cookie di preferenza e poi l'header Accept-Language.
Quel cookie, aubia_locale, porta un codice di locale, nient'altro. Altri due lo accompagnano: XSRF-TOKEN per il token CSRF, che protegge dagli invii forgiati da un altro sito, e aubia-session per la sessione Laravel.
La preferenza di tema è archiviata nel localStorage, sotto la chiave aubia-theme. Il localStorage è lo spazio che il browser riserva a un dato sito, e il suo contenuto non parte in nessuna richiesta.
Questi tre cookie sono tecnici, nessuno misura l'audience né serve la pubblicità. Esenti da consenso, dispensano il sito da un banner.
Il costo e il guadagno dell'assenza
Un segreto condiviso è dichiarato in due repo, con la sua rotazione da orchestrare. Ogni iscrizione dipende dalla rete. Se aubia.cloud non risponde, il form lo dice, invece di scrivere da qualche parte aspettando giorni migliori.
Il contatore dei confermati non ha alcuna tabella da interrogare. Chiama il cloud in lettura pubblica, tiene il risultato in cache qualche decina di secondi e conserva un ultimo valore noto come soccorso in caso di guasto.
Redis resta necessario per la sessione, la cache e il rate limiter. Un po' di stato rimane, effimero.
Sull'altro piatto, il deploy non esegue alcuna migration. Il sito non ha backup da testare, nessuna cifratura a riposo da verificare, nessun indirizzo email da purgare. Le 48 ore del token e i 30 giorni di conservazione si contano lato cloud, l'unico posto che detiene qualcosa.
I limiti della scelta
Decido tra queste due liste: una dipendenza di rete e una chiave da ruotare da un lato, quattro cantieri di conformità che non esistono dall'altro.
Questa scelta non si trasferisce a un'applicazione che deve servire i suoi utenti quando la rete cade. Non reggerebbe nemmeno su un sito vetrina che crescesse fino a detenere altro che contenuto pubblico.
Sul suo perimetro attuale, il sito non ha bisogno di alcuna tabella.
Questo cantiere si racconta qui, articolo dopo articolo. Ciò che viene dopo dipende da ciò che lei ne dirà.
Si iscriva alla lista d'attesa