Aller au contenu principal

Journal de bord · Phase 0 · 3/7

Collecter des adresses email sans une seule table

aubia.dev collecte des adresses email sans base de données. Le contenu est stocké dans des fichiers plats, et chaque adresse email est envoyée vers aubia.cloud via un proxy signé.

Publié le 27 juillet 2026Mis à jour le 5 septembre 20268 min de lecture
  • architecture
  • security
  • waitlist
  • gdpr

La liste d'attente d'aubia.dev collecte des adresses email, et le dépôt qui la sert ne contient pas une seule table. Pas un modèle, pas une migration : le dossier des migrations n'existe même pas.

Retirer la base de données d'une vitrine supprime d'un coup toute une classe de préoccupations : sauvegardes, chiffrement au repos, fuite de données, purge RGPD. On ne sécurise pas ce qu'on ne détient pas.

Chaque adresse email soumise passe par la mémoire d'un worker le temps d'un appel HTTP sortant, puis disparaît. Le service qui la reçoit, la confirme et la conserve est aubia.cloud.

Une donnée personnelle ne passe de la vitrine au cloud que par un seul flux. Un proxy signé transmet la soumission au service distant, en y joignant une preuve d'origine calculée sur place.

Des fichiers plats pour tout le contenu

Les textes de la vitrine sont stockés dans des fichiers de traduction JSON, les articles de ce blog dans des Markdown versionnés avec le code. Ces fichiers plats sont lus directement sur le disque, sans passer par un serveur de base de données.

Six locales et aucune requête SQL. Le contenu se déploie avec le dépôt, sans étape de migration.

Redis reste en place, pour la session, un cache transitoire et le rate limiter. Ce dernier plafonne le nombre de soumissions acceptées d'une même origine sur une fenêtre de temps. Il travaille sur une empreinte éphémère de l'adresse email et une autre de l'adresse IP, qui expirent avec leur fenêtre.

Une exception dans ce cache : l'index du blog y est écrit sans date d'expiration. Il contient des titres, des dates et le HTML déjà rendu d'articles publics, aucune donnée personnelle.

La frontière entre aubia.dev, vitrine publique sans état, et api.aubia.cloud qui stocke la liste d'attente, les tokens et la trace du consentement ; seul un POST signé HMAC la traverse.

Le diagramme simplifie un peu : d'autres flux traversent la frontière, comme la lecture publique du compteur de confirmés. Le proxy signé est le seul qui transporte une donnée personnelle.

Un controller sans écriture

Quand vous soumettez votre adresse email, le controller reste léger. Il valide, range les champs dans un objet typé, délègue à une Action et renvoie un message flash.

Il ne connaît ni le réseau ni la signature, et il n'a aucune base où écrire. La méthode store() ne fait rien d'autre :

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'adresse email n'existe qu'en mémoire, le temps de la requête. Aucune ligne n'est écrite côté vitrine.

Le secret partagé et les pièges anti-robots

L'Action SubmitWaitlistEmail prend le relais. Elle sérialise les champs à transmettre, puis calcule une signature HMAC SHA-256 du corps obtenu, autrement dit une signature à clé partagée.

La signature du corps envoyé

Elle poste vers aubia.cloud, avec un en-tête Origin fixé par contrat. Le corps sérialisé, le secret et les en-têtes se construisent dans cet ordre :

$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 signature est calculée sur le corps exact qui part, avec un secret que les deux côtés connaissent. Le cloud rejoue le même calcul et compare.

Le secret ne quitte jamais ces deux serveurs. Un bearer token, lui, est transmis dans chaque appel, et il peut fuiter dans les journaux s'il n'y est pas expurgé.

En local, la signature se désactive par un flag de configuration, pour tester le formulaire sans configurer toute la chaîne du secret. En production, l'envoi échoue si la signature est désactivée ou la clé absente, plutôt que de poster en clair sans qu'aucune erreur ne le signale.

Deux pièges et un champ exclu

Le formulaire arrête les robots par deux pièges qu'un visiteur ne rencontre jamais : un honeypot et un délai minimal. L'article sur la préinscription les détaille.

L'un des deux touche au contrat sortant. Le champ d'horodatage rendered_at sert à la validation locale et n'atteint jamais le cloud, ce qui limite la surface du contrat et stabilise la signature.

La méthode de sortie du DTO le précise dans son commentaire et l'applique dans son tableau :

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,
    ];
}

L'horodatage est le seul champ que le DTO transporte sans le transmettre au cloud.

Ces deux pièges protègent le formulaire, et le formulaire seul. Un délai minimal se contourne par un robot soigné, capable de programmer une attente, et un honeypot par un automate qui lit la feuille de style.

Le contrat sortant est protégé autrement, par le secret partagé. Sans lui, aucune requête forgée de l'extérieur n'est acceptée.

Rejouer une requête sans inscrire deux fois

Un client HTTP naïf rejoue toute tentative manquée. La politique du client sortant dépend de ce que la vitrine sait du sort de la requête.

Une réponse reçue, même une erreur serveur, prouve que le cloud a reçu la requête. Ce qu'il en a fait reste inconnu, et le POST d'inscription ne se rejoue donc jamais dans ce cas.

Une erreur réseau, elle, ne renvoie aucune réponse. La requête n'a probablement pas atteint le cloud, et c'est le seul cas où elle est rejouée.

Le client sortant décide donc requête par requête. Deux exécutions valent parfois exactement la même chose qu'une seule.

Le GET du compteur de confirmés se retente dans tous les cas, y compris sur une erreur serveur, puisque relire un chiffre n'inscrit personne. Le POST d'inscription ne se rejoue que sur une erreur réseau, et la condition est écrite dans le 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,
)

Seule une ConnectionException déclenche une nouvelle tentative. Si le cloud a répondu, même par une erreur serveur, la vitrine mappe la réponse sur un statut métier et affiche le message correspondant.

Le consentement côté cloud

aubia.cloud génère le token de confirmation, envoie l'email, fait expirer le lien après 48 heures et conserve la trace du consentement.

Tant qu'une inscription n'est pas confirmée, aucune autre communication ne part. Les adresses email jamais confirmées sont purgées après 30 jours, la durée que la page de confidentialité publie.

Le serveur de la vitrine ne voit jamais ce token. Les pages de confirmation et de désinscription le font transiter par le navigateur, en appel direct vers le cloud, sans repasser par lui. Le reste du parcours, du champ email au compteur de confirmés, est décrit dans un autre article.

Trois cookies et une clé de thème

Tout ce que le serveur rend est identique pour tous les visiteurs, à une exception près : la racine. / redirige vers /fr, /en ou une autre des six locales, selon un cookie de préférence puis l'en-tête Accept-Language.

Ce cookie, aubia_locale, contient un code de locale, rien d'autre. Deux autres l'accompagnent : XSRF-TOKEN pour le token CSRF, qui protège des soumissions forgées depuis un autre site, et aubia-session pour la session Laravel.

La préférence de thème est stockée dans le localStorage, sous la clé aubia-theme. Le localStorage est l'espace que le navigateur réserve à un site donné, et son contenu ne part dans aucune requête.

Ces trois cookies sont techniques, aucun ne mesure l'audience ni ne sert la publicité. Exemptés de consentement, ils dispensent la vitrine de bandeau.

Le coût et le gain de l'absence

Un secret partagé est déclaré dans deux dépôts, avec sa rotation à orchestrer. Chaque inscription dépend du réseau. Si aubia.cloud ne répond pas, le formulaire le signale, au lieu d'écrire quelque part en attendant des jours meilleurs.

Le compteur de confirmés n'a aucune table à interroger. Il appelle le cloud en lecture publique, garde le résultat en cache quelques dizaines de secondes et conserve une dernière valeur connue en cas de panne.

Redis reste requis pour la session, le cache et le rate limiter. Un peu d'état demeure, éphémère.

En face, le déploiement ne joue aucune migration. La vitrine n'a aucune sauvegarde à tester, aucun chiffrement au repos à auditer, aucune adresse email à purger. Les 48 heures du token et les 30 jours de rétention se comptent côté cloud, au seul endroit où quelque chose est stocké.

Les limites de ce compromis

Je choisis entre ces deux listes : une dépendance réseau et une clé à renouveler d'un côté, quatre chantiers de conformité qui n'existent pas de l'autre.

Ce compromis ne se transpose pas à une application qui doit servir ses utilisateurs quand le réseau tombe. Il ne vaudrait pas non plus pour une vitrine qui grossirait jusqu'à stocker autre chose que du contenu public.

Sur son périmètre actuel, la vitrine n'a besoin d'aucune table.

Ce chantier se raconte ici, article après article. Ce qui vient ensuite dépend de ce que vous en direz.

Rejoindre la liste d'attente