Skip to content

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, gotenberg n'a ni port publié ni env_file (il ne doit jamais voir APP_KEY/DB_*) ; seul le backend lui parle, via GOTENBERG_URL=http://gotenberg:3000 dans le .env de 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.yml forwarde 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 via php artisan serve ; GOTENBERG_URL fait partie de la whitelist ServeCommand::$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 total
  • encode(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 middleware ValidateActivationCodePdfSignature, qui remplace ensuite le paramètre de route par l'UUID canonique — le contrôleur (et le ref de 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 via ActivationCodePdfUrlSigner::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)PDFSinon : reason du redirect
purchased, offered, pre-active✅ servi
in stocknot_purchased
active, usedalready_activated
expired, pending-extensionexpired
pending-reimbursementpending_reimbursement
cancelledcancelled
reimbursedreimbursed
id inconnunot_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_pass remplace le préfixe /pdf/ par /api/pdf/ ; 8800 = port publié du backend, voir deploiement.md) :

    nginx
    location /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/pdf avec la même réécriture de préfixe, comme l'entrée /img existante. 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=pdf exé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.

Contributors

No contributors

Changelog

No recent changes