Génération de PDF — Gotenberg
Le document imprimable d'un code d'activation (GET /api/pdf/{token}, partagé sous la forme publique {FRONTEND_URL}/pdf/{token}) n'est jamais persisté : il est rendu à la volée par un sidecar Gotenberg (Chromium headless) au moment du clic, sur le modèle des artefacts dérivés d'imgproxy (voir stockage-et-images.md). Le backend construit un HTML autonome (CSS inline, QR en SVG inline — les conteneurs n'ont ni GD ni Imagick, d'où bacon/bacon-qr-code avec writer SVG), le poste à Gotenberg (App\Services\GotenbergPdfRenderer) et renvoie les octets PDF inline.
Topologie
clic sur {frontend}/pdf/{token} ──▶ proxy public (réécrit /pdf/ → /api/pdf/) ──▶ backend ──▶ gotenberg:3000
│
└─ état non servable ─▶ 302 {frontend}/codes/unavailable- Sidecar backend-only : dans
docker-compose.deploy.yml,gotenbergn'a ni port publié nienv_file(il ne doit jamais voirAPP_KEY/DB_*) ; seul le backend lui parle, viaGOTENBERG_URL=http://gotenberg:3000dans le.envde la VM. Le nginx de l'hôte n'est pas touché — contrairement à imgproxy, rien ne route vers Gotenberg depuis l'extérieur. - En dev,
docker-compose.ymlforwarde le port (FORWARD_GOTENBERG_PORT, défaut 3009 — 3000 est pris par le serveur de dev Vue) parce que l'app tourne sur l'hôte viaphp artisan serve;GOTENBERG_URLfait partie de la whitelistServeCommand::$passthroughVariables. - Limite mémoire 600M en deploy : chaque conversion lance un rendu Chromium, plus pointu que les pics d'imgproxy (300M).
- Échec du rendu (sidecar injoignable, timeout, non-200) → 503, jamais une 500 muette (
App\Exceptions\PdfRenderingException).
Le token — format compact, signature éternelle, barrière à l'état
Le lien est partagé nu (WhatsApp, e-mails) et imprimé + encodé en QR sur le voucher : c'est donc un unique token opaque, pas un UUID suivi d'une query string. Format (codec dans App\Services\ActivationCodePdfUrlSigner, verrouillé par vecteurs connus dans son test unitaire) :
token = base64url(16 octets bruts de l'UUID) ++ signature compacte
└── 22 caractères ──────────────────┘ └── 11 caractères ──┘
offsets fixes, aucun délimiteur — 33 caractères au totalencode(id)est déterministe ;decode(token)est strict : longueur exacte, alphabet base64url, encodage canonique uniquement (les 4 bits inutilisés du 22ᵉ caractère doivent être nuls — un id n'a qu'un seul token), HMAC vérifié. Tout le reste →null, donc 403 dans le middlewareValidateActivationCodePdfSignature, qui remplace ensuite le paramètre de route par l'UUID canonique — le contrôleur (et lerefde l'exit-hatch, que les superadmins cherchent par clé primaire) travaillent en id simple.- La route contraint
{token}par[A-Za-z0-9_-]{33}(longueur dérivée de la config viaActivationCodePdfUrlSigner::tokenLengthFor()) : une forme invalide meurt en 404 au routeur, sans toucher le middleware.
La signature garde la mécanique imgproxy : HMAC-SHA256 sur la charge activation-code-pdf:{id}, tronqué à ACTIVATION_CODE_PDF_SIGNATURE_SIZE octets (défaut 8) puis base64url — 11 caractères au lieu des 64 hexadécimaux d'une route signed Laravel. La clé est APP_KEY (comme les routes signées Laravel) : une rotation d'APP_KEY retire donc tous les liens déjà partagés. La signature prouve seulement que le lien vient de nous ; la barrière produit/sécurité est le contrôle d'état au moment du clic, lu depuis la vue activation_codes_with_status (règle SQL-first — la colonne calculée de la vue, pas l'accessor PHP).
L'URL est construite en un seul endroit, ActivationCode::pdfUrl(), sur la base FRONTEND_URL (la même config que le redirect exit-hatch), pas route()/APP_URL : /pdf/ vit sur l'origine publique et le proxy réécrit le préfixe vers /api/pdf/, exactement comme /img/ pour les images.
Conséquence voulue : renouveler un code ressuscite tous les liens déjà envoyés — même URL, aucune régénération. Un lien imprimé il y a un an redevient servable dès que le code repasse dans un statut servable.
Statut (ActivationCodeStatus) | Sinon : reason du redirect | |
|---|---|---|
purchased, offered, pre-active | ✅ servi | — |
in stock | ❌ | not_purchased |
active, used | ❌ | already_activated |
expired, pending-extension | ❌ | expired |
pending-reimbursement | ❌ | pending_reimbursement |
cancelled | ❌ | cancelled |
reimbursed | ❌ | reimbursed |
| id inconnu | ❌ | not_found |
Le mapping vit dans ActivationCodeRejectionReason::forPdfUnavailableStatus() (vocabulaire réutilisé des rejets claim/gift — valeurs stables, contractuelles pour la page frontend) ; la servabilité dans ActivationCodeStatus::isPdfServable().
Routage /pdf/
Même astuce de réécriture de préfixe que /img/ (stockage-et-images.md) — la forme courte publique n'existe que sur le proxy, l'app ne sert que /api/pdf/{token} :
Staging/prod — le nginx de l'hôte doit porter la location (le chemin final de
proxy_passremplace le préfixe/pdf/par/api/pdf/; 8800 = port publié du backend, voir deploiement.md) :nginxlocation /pdf/ { proxy_pass http://127.0.0.1:8800/api/pdf/; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; }⚠️ Ce bloc doit être en place avant la première release qui embarque le format — même étape de déploiement que
/img/en son temps.Dev — le proxy Vite du frontend mappe
/pdf→ backend/api/pdfavec la même réécriture de préfixe, comme l'entrée/imgexistante. Aucun nginx local.
Boucle de sortie (exit-hatch)
Un état non servable 302-redirige vers {FRONTEND_URL}/codes/unavailable?reason={reason}&ref={id} : le cliqueur est un voyageur ou le destinataire d'un cadeau, pas un consommateur d'API. La page frontend affiche un message par reason et propose un mailto: vers l'alias support avec le ref prérempli — le message clef : demandez un renouvellement, le même lien refonctionnera.
Chemin d'upgrade documenté (à construire seulement si le volume de la boîte support l'exige) : remplacer le mailto: par un POST anonyme signé qui tamponne extension_requested_at, en réutilisant la garde d'idempotence has_open_extension_request existante — le vocabulaire et la plomberie sont déjà en place.
Prévisualisation en dev
GET /api/dev/activation-codes/pdf-preview/{id?} — route enregistrée uniquement quand APP_ENV ∈ {local, testing} (bloc conditionnel de routes/api.php) et exclue du contrat OpenAPI (#[ExcludeAllRoutesFromDocs]). Elle saute la signature et la barrière d'état : c'est un outil de débogage du template, pas une surface produit.
- Sans paramètre : le code le plus récent ; sinon l'UUID voulu.
- Par défaut elle renvoie le HTML brut du voucher — itération sur le template Blade au simple refresh navigateur, devtools compris.
?format=pdfexécute le vrai aller-retour Gotenberg.
L'assemblage du document (données de vue, QR, assets en data URI) est partagé avec la route réelle via App\Services\ActivationCodePdfDocument — ce qu'on prévisualise est exactement ce qui est servi.
Notes d'exploitation
- Compteur de rendus > compteur de clics : les crawlers d'unfurl (WhatsApp, Slack, iMessage…) récupèrent l'URL dès qu'elle est partagée. C'est pourquoi le throttle est lâche (
throttle:30,1) — un fetch de préversion et le clic humain doivent tous deux passer. Ne pas s'étonner de rendus « en trop » dans les métriques. - Partage WhatsApp : recommander de partager le lien, pas le fichier. Un PDF transféré est un instantané figé, mais il s'auto-répare : son pied de page embarque sa propre URL signée en texte et en QR — n'importe quelle copie papier ou transférée ramène au document frais.
- Les lectures ne sont pas auditées (cohérent avec le reste de l'app) ; pas de trace par rendu.

