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',