From ad104c82d70c7ab9d8e0ecc7806033c27862e28f Mon Sep 17 00:00:00 2001 From: Stephan Morand Date: Fri, 21 Aug 2026 10:53:17 +0200 Subject: [PATCH] =?UTF-8?q?Rendre=20le=20d=C3=A9ploiement=20possible=20:?= =?UTF-8?q?=20image=20de=20production,=20amor=C3=A7age,=20langues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'image de production existait mais n'était jamais partie. Quatre manques la rendaient inexploitable, tous silencieux — l'application démarrait dans les quatre cas. Le front n'était pas dans l'image public/build et public/index.html sont gitignorés, et le Dockerfile n'avait pas d'étage Node. L'API répondait, chaque URL de l'interface renvoyait 503 : une panne partielle, donc plus déroutante qu'une panne franche. Étage app_frontend ajouté, avec une vérification au build qui échoue si le front manque. Aucune migration n'était appliquée Seul l'entrypoint de dev le faisait. docker/entrypoint.prod.sh applique les migrations sous garde RUN_MIGRATIONS et refuse de démarrer sur un APP_SECRET vide, un DATABASE_URL absent ou un BACK_OFFICE_URL sur localhost — cette dernière valeur ne casse rien de visible, elle casse la réception des invitations, ailleurs, plusieurs jours plus tard. Aucun moyen de créer le premier compte Les comptes s'obtiennent par invitation, et inviter demande d'être connecté. Les fixtures sont une dépendance de dev. Il ne restait que l'INSERT à la main. app:create-super-admin crée l'organisation et le premier compte, refuse une adresse connue, exige douze caractères. Le référentiel de langues était vide Créé par les fixtures, donc absent en production. Sans langue source à choisir, aucun projet ne pouvait être créé. Passé en migration : c'est une donnée de référence, pas un jeu d'exemple. Au passage : l'expéditeur des courriels était en dur sur no-reply@tq-slator.local, qu'un relais SMTP réel rejette. Devenu MAILER_FROM. compose.prod.yaml est un fichier distinct, et c'est le point qui compte : Docker fusionne compose.override.yaml dès que le principal s'appelle compose.yaml, si bien que Mailpit et les ports publiés seraient partis en production sans que personne ne l'ait demandé. Parcours vérifié sur une pile neuve : connexion, création de projet, plateforme, push de clés, publication, clé API, fichier livré. Co-Authored-By: Claude Opus 5 --- .dockerignore | 54 ++++++ .env | 3 + Dockerfile | 44 ++++- README.md | 101 +++++++++++ compose.prod.yaml | 160 ++++++++++++++++++ config/services.yaml | 5 + docker/entrypoint.prod.sh | 92 ++++++++++ migrations/Version20260821090000.php | 106 ++++++++++++ src/Command/CreateSuperAdminCommand.php | 133 +++++++++++++++ .../SendInvitationEmailHandler.php | 3 +- 10 files changed, 699 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 compose.prod.yaml create mode 100755 docker/entrypoint.prod.sh create mode 100644 migrations/Version20260821090000.php create mode 100644 src/Command/CreateSuperAdminCommand.php diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7068682 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,54 @@ +# Ce que le contexte de build ne doit PAS emporter. +# +# Absent jusqu'ici parce que l'image de dev monte les sources en volume et ne +# copie rien. L'étage de production, lui, fait `COPY . ./` : sans ce fichier, +# il embarquait node_modules, vendor, l'historique Git et les caches locaux — +# plusieurs centaines de mégaoctets, et surtout le risque d'écraser dans +# l'image un vendor/ construit pour une autre plateforme que la cible. + +# ─── Dépendances : reconstruites dans leurs étages dédiés ─────────────────── +/vendor/ +/node_modules/ + +# ─── Sorties de build : reconstruites, jamais reprises de l'hôte ──────────── +# Reprendre le public/build de la machine du développeur ferait dépendre le +# contenu de l'image de ce qu'il avait lancé la veille. +/public/build/ +/public/index.html +/build/ +/.vite/ + +# ─── État local ──────────────────────────────────────────────────────────── +/var/ +/.env.local +/.env.local.php +/.env.*.local +/.phpstan.cache/ +/.phpunit.cache/ +/phpunit.xml +/phpstan.neon.local +/.php-cs-fixer.cache + +# ─── Historique et outillage ─────────────────────────────────────────────── +/.git/ +/.gitignore +/.github/ +/.gstack/ +/.idea/ +/.vscode/ +.DS_Store + +# ─── Ce qui ne sert qu'au développement ──────────────────────────────────── +# +# `docker/` n'est PAS exclu : l'étage dev y copie son entrypoint et l'étage +# prod le sien. Les exclure a cassé la construction de l'image de dev, avec un +# message peu parlant — « failed to calculate checksum: not found » — parce que +# Docker ne dit pas qu'un fichier a été ignoré, seulement qu'il est absent. +/compose.yaml +/compose.override.yaml +/compose.prod.yaml +/tests/ +/phpunit.dist.xml +/phpstan.neon +/.php-cs-fixer.dist.php +/.editorconfig diff --git a/.env b/.env index b588016..9c67a00 100644 --- a/.env +++ b/.env @@ -33,6 +33,9 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' ###> symfony/mailer ### # Mailpit en local : toutes les invitations sont capturées sur http://localhost:8025 MAILER_DSN=smtp://mailer:1025 +# Expéditeur des invitations. En production, un domaine réellement délivrable : +# un « .local » est rejeté par les relais SMTP, ou classé indésirable. +MAILER_FROM=no-reply@tq-slator.local ###< symfony/mailer ### ###> symfony/messenger ### diff --git a/Dockerfile b/Dockerfile index 0a0845e..36a2582 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ # en 8.5 sans que cela change ce qui sera déployé. ARG PHP_VERSION=8.4 ARG FRANKENPHP_VERSION=1 +ARG NODE_VERSION=22 # ───────────────────────────────────────────────────────────────────────────── # base — runtime commun dev/prod @@ -77,6 +78,30 @@ COPY --link composer.json composer.lock symfony.lock ./ RUN --mount=type=cache,target=/tmp/composer \ composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress +# ───────────────────────────────────────────────────────────────────────────── +# frontend — construction du back-office React +# ───────────────────────────────────────────────────────────────────────────── +# Indispensable, et pas une commodité : `public/build/` et `public/index.html` +# sont dans .gitignore. Une image construite depuis le dépôt sans cet étage +# démarre sans back-office — SpaController répond alors 503 « Le back-office +# n'est pas construit » sur chaque URL de l'interface. L'API, elle, fonctionne : +# la panne est donc partielle, ce qui la rend plus déroutante encore. +FROM node:${NODE_VERSION}-alpine AS app_frontend + +WORKDIR /build + +# Les manifestes d'abord : cette couche ne se reconstruit que lorsque les +# dépendances changent, pas à chaque modification d'un composant. +COPY --link package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund + +COPY --link tsconfig.json vite.config.ts ./ +COPY --link assets ./assets + +# `npm run build` lance `tsc --noEmit && vite build` : une erreur de typage +# casse donc le déploiement ici, et non en production. +RUN npm run build + # ───────────────────────────────────────────────────────────────────────────── # prod — image finale, worker mode activé # ───────────────────────────────────────────────────────────────────────────── @@ -92,13 +117,30 @@ COPY --link frankenphp/conf.d/20-app.prod.ini $PHP_INI_DIR/app.conf.d/ COPY --from=app_vendor --link /app/vendor ./vendor COPY --link . ./ +# Le front APRÈS les sources : `COPY . ./` écraserait sinon public/ avec la +# version du dépôt, qui ne contient que index.php. +COPY --from=app_frontend --link /build/public/index.html ./public/index.html +COPY --from=app_frontend --link /build/public/build ./public/build + RUN set -eux; \ mkdir -p var/cache var/log var/storage; \ composer dump-autoload --classmap-authoritative --no-dev; \ composer dump-env prod; \ composer run-script --no-dev post-install-cmd; \ - chmod +x bin/console; \ + chmod +x bin/console docker/entrypoint.prod.sh; \ setfacl -R -m u:www-data:rwX -m u:root:rwX var; \ setfacl -dR -m u:www-data:rwX -m u:root:rwX var +# Vérification au BUILD que le back-office est bien dans l'image. Le contrôleur +# dégrade proprement en 503 s'il manque — ce qui est le bon comportement en +# exécution, mais transformerait ici une erreur de build en panne silencieuse +# découverte par le premier utilisateur. +RUN test -f public/index.html && test -d public/build \ + || (echo 'Le front est absent de l’image : étage app_frontend cassé.' >&2 && exit 1) + +# CMD est redéclaré parce que Docker le remet à zéro dès qu'un étage redéfinit +# ENTRYPOINT — le même piège que dans l'étage dev. +ENTRYPOINT ["docker-php-entrypoint", "docker/entrypoint.prod.sh"] +CMD ["frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile"] + VOLUME /app/var/storage diff --git a/README.md b/README.md index a6a1934..4c99bdc 100644 --- a/README.md +++ b/README.md @@ -387,6 +387,107 @@ redistribué dans chaque clé i18next (rendu identique, structure différente), le type d'un argument ICU est perdu (le nom survit). Voir `docs/01-architecture-proposal.md` §4.3. +## Déploiement + +La pile de développement (`compose.yaml`) ne part **pas** en production : elle +cible l'image `app_dev`, monte les sources en volume, publie des ports et +branche Mailpit. `compose.override.yaml`, que Docker fusionne automatiquement, +en rajouterait. La production a donc son propre fichier : + +```bash +docker compose -f compose.prod.yaml up -d --build +``` + +### Ce que l'image de production contient + +Le back-office React est construit **dans l'image**, par un étage Node dédié. +C'est indispensable et non une commodité : `public/build/` et +`public/index.html` sont dans `.gitignore`. Sans cet étage, l'application +démarre, l'API répond, et chaque URL de l'interface renvoie 503 « Le back-office +n'est pas construit » — une panne partielle, donc plus déroutante qu'une panne +franche. Une vérification au build échoue si le front manque. + +`npm run build` lance `tsc --noEmit` avant Vite : une erreur de typage casse le +déploiement, pas la production. + +### Variables à renseigner + +Quatre sont obligatoires. Absentes, le déploiement s'arrête au lieu de démarrer +un service à moitié configuré. + +| Variable | Rôle | +|---|---| +| `APP_SECRET` | Signature des cookies de session. `openssl rand -hex 32` | +| `MARIADB_PASSWORD` | Mot de passe applicatif MariaDB | +| `MARIADB_ROOT_PASSWORD` | Mot de passe root MariaDB | +| `BACK_OFFICE_URL` | URL **publique**, `https://…`. Sert aux liens d'invitation | +| `MAILER_DSN` | Relais SMTP réel. Mailpit ne part pas en production | + +Facultatives : `MAILER_FROM` (expéditeur, défaut `no-reply@tranquilys.com`), +`DEFAULT_ORGANIZATION_SLUG`, `RELEASE_RETENTION_UNREFERENCED`, +`SYMFONY_TRUSTED_PROXIES`, `CORS_ALLOW_ORIGIN`, `INVITATION_TTL`. + +`BACK_OFFICE_URL` sur `localhost` est **refusé au démarrage**. Une valeur fausse +ne casse rien de visible : elle casse la réception des invitations, ailleurs, +plusieurs jours plus tard. + +### Premier démarrage + +Les comptes s'obtiennent par invitation, et inviter demande d'être connecté. +Une installation neuve a donc besoin d'un amorçage : + +```bash +docker compose -f compose.prod.yaml exec php \ + php bin/console app:create-super-admin vous@exemple.fr "Votre Nom" +``` + +Le mot de passe est demandé sans être affiché — il ne reste donc pas dans +l'historique du shell. Douze caractères minimum, comme à l'acceptation d'une +invitation : un premier compte plus faible que les suivants serait le maillon +par lequel on entre. + +La commande refuse une adresse déjà connue : elle deviendrait sinon un moyen de +s'attribuer le compte d'un autre. + +Le référentiel de langues est installé par une **migration**, pas par les +fixtures — c'est une donnée de référence, identique pour tout le monde, et sans +elle aucun projet ne peut être créé faute de langue source à choisir. + +### Déployer via Coolify + +1. **Nouvelle ressource → Docker Compose**, dépôt + `ssh://git@git.tranquilys.com:22222/Stephan/TQ-Slator.git`, branche `main`. +2. Fichier compose : `compose.prod.yaml`. Ne pas laisser `compose.yaml`, que + Coolify propose par défaut. +3. Renseigner les variables du tableau ci-dessus dans l'onglet *Environment*. +4. Attribuer le domaine au service **`php`**. `SERVICE_FQDN_PHP_80` est déjà + déclaré dans le fichier : Coolify y place le domaine et configure son proxy. +5. Déployer, puis lancer la commande d'amorçage ci-dessus depuis le terminal de + la ressource. + +Le TLS est terminé par le proxy de Coolify ; Caddy a donc `auto_https off`, et +`SYMFONY_TRUSTED_PROXIES` est renseigné pour que Symfony lise +`X-Forwarded-Proto` — sans quoi les liens d'invitation partiraient en `http://` +depuis un site en `https://`. + +Aucun port n'est publié sur l'hôte : le proxy atteint les conteneurs par le +réseau interne. Sur une VM sans Coolify, retirer `SERVICE_FQDN_PHP_80` et +ajouter `ports: ['80:80']` au service `php`. + +### Ce que le déploiement fait tout seul + +- **Migrations** — appliquées au démarrage par le service `php`, et par lui + seul. Le `worker` attend que le schéma soit à jour avant de consommer, sinon + il polluerait les journaux du premier déploiement avec des erreurs qui n'en + sont pas. +- **Cache applicatif** — figé au build, jamais régénéré au démarrage : deux + conteneurs issus de la même image doivent être identiques. + +### Volumes à conserver + +`db_data` et `app_storage`. Le second porte les captures d'écran de contexte et +les pièces jointes — le seul état applicatif hors base. + ## Développement ```bash diff --git a/compose.prod.yaml b/compose.prod.yaml new file mode 100644 index 0000000..88e1c55 --- /dev/null +++ b/compose.prod.yaml @@ -0,0 +1,160 @@ +# Pile de production — destinée à Coolify, utilisable telle quelle sur une VM. +# +# Fichier DISTINCT de compose.yaml, et c'est le point qui compte le plus ici : +# `docker compose` fusionne automatiquement compose.override.yaml dès que le +# fichier principal s'appelle compose.yaml. L'override publie des ports et +# branche Mailpit ; il partirait donc en production sans que personne ne l'ait +# demandé. Un nom explicite ferme la porte. +# +# Trois différences de fond avec la pile de développement : +# - cible d'image `app_prod` (worker mode FrankenPHP, cache figé au build), +# et non `app_dev` ; +# - aucune source montée : ce qui tourne est ce qui a été construit ; +# - aucun port publié sur l'hôte — le proxy de Coolify atteint les conteneurs +# par le réseau interne. Publier 8080 exposerait l'application en clair, à +# côté du HTTPS, ce qui annulerait ce que le proxy vient de faire. + +services: + php: + build: + context: . + target: app_prod + environment: + # Coolify remplace cette variable par le domaine attribué au service et + # configure son proxy en conséquence. Sur une VM sans Coolify, retirez + # cette ligne et ajoutez `ports: ['80:80']`. + SERVICE_FQDN_PHP_80: '/' + + SERVER_NAME: ':80' + APP_ENV: prod + APP_DEBUG: '0' + + # Le TLS est terminé par le proxy. Laisser Caddy tenter d'obtenir un + # certificat produirait des échecs ACME en boucle sur un domaine qu'il ne + # contrôle pas. + CADDY_GLOBAL_OPTIONS: 'auto_https off' + + # Le proxy est le seul à parler au conteneur : sans cette liste, Symfony + # ignore X-Forwarded-Proto et fabrique des URL en http:// derrière un + # site en https:// — les liens d'invitation partiraient en clair. + # Nom imposé par Symfony (framework.trusted_proxies le lit d'office) ; + # « TRUSTED_PROXIES » tout court ne serait lu par personne. + SYMFONY_TRUSTED_PROXIES: '${SYMFONY_TRUSTED_PROXIES:-127.0.0.1,REMOTE_ADDR}' + # Le back-office est servi en même origine que l'API : le navigateur + # n'émet donc aucune requête cross-origin pour lui. Cette valeur ne + # concerne que la Delivery API appelée depuis un autre domaine. + CORS_ALLOW_ORIGIN: '${CORS_ALLOW_ORIGIN:-^$$}' + + # `?` : absente, la variable arrête le déploiement au lieu de laisser + # démarrer un service à moitié configuré. + APP_SECRET: '${APP_SECRET:?openssl rand -hex 32}' + DATABASE_URL: 'mysql://tqslator:${MARIADB_PASSWORD:?mot de passe MariaDB}@database:3306/tqslator?serverVersion=11.4.0-MariaDB&charset=utf8mb4' + REDIS_URL: 'redis://cache:6379' + + # Sert à composer les liens d'invitation envoyés par e-mail. Une valeur + # fausse ne casse rien de visible ici : elle casse la réception, ailleurs, + # plus tard. L'entrypoint refuse donc localhost. + BACK_OFFICE_URL: '${BACK_OFFICE_URL:?URL publique du back-office}' + MAILER_DSN: '${MAILER_DSN:?DSN SMTP réel, Mailpit ne part pas en production}' + MAILER_FROM: '${MAILER_FROM:-no-reply@tranquilys.com}' + + DEFAULT_ORGANIZATION_SLUG: '${DEFAULT_ORGANIZATION_SLUG:-tranquilys}' + RELEASE_RETENTION_UNREFERENCED: '${RELEASE_RETENTION_UNREFERENCED:-5}' + INVITATION_TTL: '${INVITATION_TTL:-P7D}' + INVITATION_TTL_EXTERNAL: '${INVITATION_TTL_EXTERNAL:-P2D}' + + # Ce service est le SEUL à migrer. Voir docker/entrypoint.prod.sh. + RUN_MIGRATIONS: '1' + depends_on: + database: + condition: service_healthy + cache: + condition: service_healthy + volumes: + # Captures d'écran de contexte et pièces jointes. Le seul état applicatif + # hors base : sans ce volume, un redéploiement les efface. + - app_storage:/app/var/storage + restart: unless-stopped + + worker: + build: + context: . + target: app_prod + environment: + APP_ENV: prod + APP_DEBUG: '0' + APP_SECRET: '${APP_SECRET:?openssl rand -hex 32}' + DATABASE_URL: 'mysql://tqslator:${MARIADB_PASSWORD:?mot de passe MariaDB}@database:3306/tqslator?serverVersion=11.4.0-MariaDB&charset=utf8mb4' + REDIS_URL: 'redis://cache:6379' + BACK_OFFICE_URL: '${BACK_OFFICE_URL:?URL publique du back-office}' + MAILER_DSN: '${MAILER_DSN:?DSN SMTP réel}' + MAILER_FROM: '${MAILER_FROM:-no-reply@tranquilys.com}' + DEFAULT_ORGANIZATION_SLUG: '${DEFAULT_ORGANIZATION_SLUG:-tranquilys}' + # Ne migre pas : il ATTEND que le schéma soit à jour. + RUN_MIGRATIONS: '0' + depends_on: + database: + condition: service_healthy + # Attend que `php` ait fini de migrer. Sans cela le consommateur démarre + # sur une base sans table messenger_messages, échoue, redémarre — il + # finirait par s'en sortir, mais en polluant les journaux du premier + # déploiement avec des erreurs qui ne sont pas des erreurs. + php: + condition: service_healthy + volumes: + - app_storage:/app/var/storage + # `--time-limit` fait sortir le processus périodiquement : c'est le moyen + # standard de rendre à l'OS la mémoire qu'un worker PHP finit par retenir. + # `restart` le relance aussitôt. + command: ['php', 'bin/console', 'messenger:consume', 'async', '--time-limit=3600', '--memory-limit=192M', '-v'] + # Le healthcheck hérité interroge l'endpoint admin de Caddy, que ce service + # ne fait pas tourner. Le consommateur est PID 1 : s'il meurt, le conteneur + # sort et Docker le relance. Une sonde ne ferait que dupliquer cela. + healthcheck: + disable: true + restart: unless-stopped + + database: + image: mariadb:11.4 + environment: + MARIADB_DATABASE: tqslator + MARIADB_USER: tqslator + MARIADB_PASSWORD: '${MARIADB_PASSWORD:?mot de passe MariaDB}' + MARIADB_ROOT_PASSWORD: '${MARIADB_ROOT_PASSWORD:?mot de passe root MariaDB}' + command: + # Doit correspondre à default_table_options de doctrine.yaml, sinon les + # tables créées hors migration divergent en silence. Voir §3.6 de + # docs/01-architecture-proposal.md. + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_uca1400_ai_ci + - --innodb-default-row-format=dynamic + # Pas de log des requêtes sans index en production : à ce volume il + # remplirait le disque sans rien apprendre qu'on ne sache déjà. + - --slow-query-log=1 + - --long-query-time=1 + healthcheck: + test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'] + interval: 10s + timeout: 5s + retries: 20 + start_period: 60s + volumes: + - db_data:/var/lib/mysql + restart: unless-stopped + + cache: + image: redis:7-alpine + # Ni RDB ni AOF : ce Redis ne porte que du cache et du verrouillage de + # connexion. Le persister donnerait l'illusion d'une donnée qu'on peut + # perdre sans conséquence — et qu'on perdra donc mal. + command: ['redis-server', '--save', '', '--appendonly', 'no', '--maxmemory', '256mb', '--maxmemory-policy', 'allkeys-lru'] + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 10s + timeout: 3s + retries: 10 + restart: unless-stopped + +volumes: + db_data: + app_storage: diff --git a/config/services.yaml b/config/services.yaml index 6b9428d..a89626c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -12,6 +12,10 @@ parameters: # peut pas être déduite d'une requête : les courriels partent depuis un # worker, hors contexte HTTP. app.back_office_url: '%env(BACK_OFFICE_URL)%' + # Expéditeur des courriels sortants. Paramétrable parce qu'un domaine + # en dur ne survit pas au premier relais SMTP réel : un `.local` est + # rejeté, ou classé indésirable, selon l'humeur du destinataire. + app.mailer_from: '%env(MAILER_FROM)%' app.invitation_ttl: '%env(INVITATION_TTL)%' app.invitation_ttl_external: '%env(INVITATION_TTL_EXTERNAL)%' @@ -30,6 +34,7 @@ services: string $invitationTtl: '%app.invitation_ttl%' string $invitationTtlExternal: '%app.invitation_ttl_external%' string $backOfficeUrl: '%app.back_office_url%' + string $mailerFrom: '%app.mailer_from%' App\: resource: '../src/' diff --git a/docker/entrypoint.prod.sh b/docker/entrypoint.prod.sh new file mode 100755 index 0000000..c24817f --- /dev/null +++ b/docker/entrypoint.prod.sh @@ -0,0 +1,92 @@ +#!/bin/sh +set -e + +# Démarrage d'un conteneur de production. +# +# Distinct de l'entrypoint de dev, et non une variante paramétrée : celui-ci ne +# doit JAMAIS lancer `composer install` ni régénérer le cache applicatif. Tout +# cela est figé dans l'image au build, et le refaire au démarrage rendrait deux +# conteneurs issus de la même image potentiellement différents — exactement ce +# qu'un déploiement reproductible cherche à éviter. +# +# Il ne reste donc que ce qui dépend de l'environnement d'exécution : la base. + +# ── Garde-fous de configuration ─────────────────────────────────────────────── +# +# Échouer ici, bruyamment, plutôt que démarrer un service à moitié configuré. +# Une application qui répond avec un APP_SECRET vide est une application dont +# les cookies de session sont forgeables ; mieux vaut qu'elle ne réponde pas. + +if [ -z "${APP_SECRET:-}" ]; then + echo '[entrypoint] APP_SECRET est vide. Générez-le une fois pour toutes :' >&2 + echo '[entrypoint] openssl rand -hex 32' >&2 + exit 1 +fi + +if [ -z "${DATABASE_URL:-}" ]; then + echo '[entrypoint] DATABASE_URL est absent.' >&2 + exit 1 +fi + +# BACK_OFFICE_URL sert à composer les liens d'invitation. Laissé sur sa valeur +# de développement, il enverrait des e-mails pointant vers localhost — une +# panne qui ne se voit que du côté du destinataire, plusieurs jours plus tard. +case "${BACK_OFFICE_URL:-}" in + ''|*localhost*|*127.0.0.1*) + echo "[entrypoint] BACK_OFFICE_URL vaut « ${BACK_OFFICE_URL:-vide} »." >&2 + echo '[entrypoint] Les liens d’invitation envoyés par e-mail seraient inutilisables.' >&2 + echo '[entrypoint] Renseignez l’URL publique du back-office.' >&2 + exit 1 + ;; +esac + +mkdir -p var/cache var/log var/storage + +# ── Attente de la base ──────────────────────────────────────────────────────── +# +# Le healthcheck du service MariaDB couvre la disponibilité du serveur ; il ne +# dit rien de la capacité de CETTE application à s'y connecter avec CES +# identifiants. La distinction compte au premier déploiement, quand un mot de +# passe erroné produirait sinon une boucle de redémarrage sans message clair. + +attempts=0 +until php bin/console dbal:run-sql 'SELECT 1' >/dev/null 2>&1; do + attempts=$((attempts + 1)) + + if [ "$attempts" -ge 30 ]; then + echo '[entrypoint] Base injoignable après 60 s. Dernière erreur :' >&2 + php bin/console dbal:run-sql 'SELECT 1' >&2 || true + exit 1 + fi + + echo "[entrypoint] attente de la base ($attempts/30)…" + sleep 2 +done + +# ── Migrations ──────────────────────────────────────────────────────────────── +# +# Un seul service migre. Sans ce garde-fou, `php` et `worker` démarrent en +# parallèle et se disputent la table de verrouillage des migrations. + +if [ "${RUN_MIGRATIONS:-0}" = "1" ]; then + echo '[entrypoint] migrations…' + php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration +else + attempts=0 + until php bin/console doctrine:migrations:up-to-date >/dev/null 2>&1; do + attempts=$((attempts + 1)) + + if [ "$attempts" -ge 60 ]; then + echo '[entrypoint] Schéma toujours pas à jour après 120 s.' >&2 + echo '[entrypoint] Le service qui porte RUN_MIGRATIONS=1 a-t-il démarré ?' >&2 + exit 1 + fi + + echo "[entrypoint] attente des migrations ($attempts/60)…" + sleep 2 + done +fi + +echo '[entrypoint] prêt.' + +exec "$@" diff --git a/migrations/Version20260821090000.php b/migrations/Version20260821090000.php new file mode 100644 index 0000000..e511b08 --- /dev/null +++ b/migrations/Version20260821090000.php @@ -0,0 +1,106 @@ +}> + */ + private const LOCALES = [ + ['fr', 'French', 'Français', 'ltr', ['one', 'many', 'other']], + ['fr-FR', 'French (France)', 'Français (France)', 'ltr', ['one', 'many', 'other']], + ['fr-CA', 'French (Canada)', 'Français (Canada)', 'ltr', ['one', 'many', 'other']], + ['en', 'English', 'English', 'ltr', ['one', 'other']], + ['en-GB', 'English (UK)', 'English (UK)', 'ltr', ['one', 'other']], + ['en-US', 'English (US)', 'English (US)', 'ltr', ['one', 'other']], + ['es-ES', 'Spanish (Spain)', 'Español (España)', 'ltr', ['one', 'many', 'other']], + ['de-DE', 'German (Germany)', 'Deutsch (Deutschland)', 'ltr', ['one', 'other']], + ['it-IT', 'Italian (Italy)', 'Italiano (Italia)', 'ltr', ['one', 'many', 'other']], + ['pt-BR', 'Portuguese (Brazil)', 'Português (Brasil)', 'ltr', ['one', 'many', 'other']], + ['pt-PT', 'Portuguese (Portugal)', 'Português (Portugal)', 'ltr', ['one', 'many', 'other']], + ['nl-NL', 'Dutch (Netherlands)', 'Nederlands', 'ltr', ['one', 'other']], + // L'arabe est présent expressément : six formes et sens d'écriture + // inversé, c'est la langue qui casse les éditeurs mal conçus. + ['ar', 'Arabic', 'العربية', 'rtl', ['zero', 'one', 'two', 'few', 'many', 'other']], + ['he', 'Hebrew', 'עברית', 'rtl', ['one', 'two', 'many', 'other']], + ['pl-PL', 'Polish (Poland)', 'Polski', 'ltr', ['one', 'few', 'many', 'other']], + ['ru-RU', 'Russian (Russia)', 'Русский', 'ltr', ['one', 'few', 'many', 'other']], + ['ja-JP', 'Japanese (Japan)', '日本語', 'ltr', ['other']], + ['zh-CN', 'Chinese (Simplified)', '简体中文', 'ltr', ['other']], + ]; + + public function getDescription(): string + { + return 'Installe le référentiel de langues : sans lui, aucun projet ne peut être créé.'; + } + + public function up(Schema $schema): void + { + foreach (self::LOCALES as [$code, $name, $nativeName, $direction, $plurals]) { + $this->addSql( + 'INSERT IGNORE INTO locale (code, name, native_name, direction, plural_categories) ' + .'VALUES (:code, :name, :nativeName, :direction, :plurals)', + [ + 'code' => $code, + 'name' => $name, + 'nativeName' => $nativeName, + 'direction' => $direction, + 'plurals' => json_encode($plurals, \JSON_THROW_ON_ERROR), + ], + ); + } + + // Chaînes de repli : une traduction manquante en fr-CA retombe sur fr + // avant de retomber sur la langue source du projet. + foreach ([['fr-FR', 'fr'], ['fr-CA', 'fr'], ['en-GB', 'en'], ['en-US', 'en'], ['pt-PT', 'pt-BR']] as [$child, $parent]) { + $this->addSql( + 'UPDATE locale c JOIN locale p ON p.code = :parent SET c.fallback_locale_id = p.id ' + .'WHERE c.code = :child AND c.fallback_locale_id IS NULL', + ['child' => $child, 'parent' => $parent], + ); + } + } + + public function down(Schema $schema): void + { + // On ne supprime que les langues qu'AUCUN projet n'utilise. Effacer une + // langue référencée casserait les traductions existantes en cascade, ce + // qu'un retour en arrière de migration ne doit jamais faire. + $this->addSql('UPDATE locale SET fallback_locale_id = NULL'); + $this->addSql( + 'DELETE FROM locale WHERE code IN (:codes) ' + .'AND id NOT IN (SELECT locale_id FROM project_locale) ' + .'AND id NOT IN (SELECT source_locale_id FROM project)', + ['codes' => array_column(self::LOCALES, 0)], + ['codes' => \Doctrine\DBAL\ArrayParameterType::STRING], + ); + } +} diff --git a/src/Command/CreateSuperAdminCommand.php b/src/Command/CreateSuperAdminCommand.php new file mode 100644 index 0000000..2922be6 --- /dev/null +++ b/src/Command/CreateSuperAdminCommand.php @@ -0,0 +1,133 @@ +addArgument('email', InputArgument::REQUIRED, 'Adresse de connexion') + ->addArgument('name', InputArgument::OPTIONAL, 'Nom affiché') + ->addOption( + 'password', + 'p', + InputOption::VALUE_REQUIRED, + 'Mot de passe. Omis, il est demandé sans être affiché — ' + .'ce qui évite de le laisser dans l\'historique du shell.', + ) + ->addOption('organization', null, InputOption::VALUE_REQUIRED, 'Nom de l\'organisation à créer si elle n\'existe pas'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $email = trim((string) $input->getArgument('email')); + + if (!filter_var($email, \FILTER_VALIDATE_EMAIL)) { + $io->error(sprintf('« %s » n\'est pas une adresse valide.', $email)); + + return Command::INVALID; + } + + // Le filtre multi-organisation n'est pas actif hors contexte HTTP, la + // recherche porte donc bien sur toute la base. + if (null !== $this->users->findOneBy(['email' => $email])) { + $io->error(sprintf( + 'Un compte existe déjà pour %s. Cette commande ne modifie jamais un compte existant : ' + .'elle deviendrait sinon un moyen de s\'attribuer celui d\'un autre.', + $email, + )); + + return Command::FAILURE; + } + + $password = $input->getOption('password'); + + if (!\is_string($password) || '' === $password) { + $question = (new Question('Mot de passe : '))->setHidden(true)->setHiddenFallback(false); + $password = (string) $io->askQuestion($question); + } + + if (mb_strlen($password) < self::MIN_PASSWORD_LENGTH) { + $io->error(sprintf('Le mot de passe doit faire au moins %d caractères.', self::MIN_PASSWORD_LENGTH)); + + return Command::INVALID; + } + + $organization = $this->entityManager + ->getRepository(Organization::class) + ->findOneBy(['slug' => $this->defaultOrganizationSlug]); + + if (null === $organization) { + $name = (string) ($input->getOption('organization') ?? ucfirst($this->defaultOrganizationSlug)); + $organization = new Organization($name, $this->defaultOrganizationSlug); + $this->entityManager->persist($organization); + + $io->text(sprintf('Organisation « %s » créée (slug : %s).', $name, $this->defaultOrganizationSlug)); + } + + $user = new User($organization, $email, trim((string) ($input->getArgument('name') ?? $email))); + $user->setRoles([User::ROLE_SUPER_ADMIN]); + $user->setPassword($this->passwordHasher->hashPassword($user, $password)); + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + $io->success(sprintf('Super-administrateur %s créé.', $email)); + $io->text([ + 'Prochaine étape : se connecter, puis créer un projet depuis « Administration ».', + 'Les comptes suivants s\'obtiennent par invitation — cette commande ne sert qu\'une fois.', + ]); + + return Command::SUCCESS; + } +} diff --git a/src/MessageHandler/SendInvitationEmailHandler.php b/src/MessageHandler/SendInvitationEmailHandler.php index e8e01a4..605fa87 100644 --- a/src/MessageHandler/SendInvitationEmailHandler.php +++ b/src/MessageHandler/SendInvitationEmailHandler.php @@ -24,6 +24,7 @@ final readonly class SendInvitationEmailHandler private InvitationRepository $invitations, private MailerInterface $mailer, private string $backOfficeUrl, + private string $mailerFrom, ) { } @@ -47,7 +48,7 @@ final readonly class SendInvitationEmailHandler $expiresIn = max(1, (int) ceil($remaining / 86400)); $email = (new Email()) - ->from('no-reply@tq-slator.local') + ->from($this->mailerFrom) ->to($invitation->getEmail()) ->subject(sprintf( 'Invitation à traduire %s',