Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .claude/deploiement.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Cible : hébergement mutualisé **o2switch**, un sous-domaine par instance (`<pr

SSH depuis la machine locale via les scripts `bin/`.

> Le déploiement automatique GitHub Actions (webhook) ne fonctionne **pas** sur o2switch — les IPs des runners GitHub Actions sont bloquées par le firewall SSH. Les scripts SSH sont la seule méthode fiable aujourd'hui (#288, en pause). Le webhook `public/deploy.php` existe dans le repo mais est cassé et inutilisé.
> Le déploiement automatique GitHub Actions (webhook) ne fonctionne **pas** sur o2switch — les IPs des runners GitHub Actions sont bloquées par le firewall SSH. Les scripts SSH sont la seule méthode fiable aujourd'hui (#288, en pause). Le webhook `public/deploy.php` existe dans le repo et est actif (vérifié HMAC-SHA256, secret `DEPLOY_WEBHOOK_SECRET`), déclenché par GitHub Actions après CI success — mais reste sans effet pratique tant que le blocage IP côté o2switch n'est pas levé.

### Piège IP dynamique / VPN d'entreprise

Expand Down Expand Up @@ -354,6 +354,27 @@ MAILER_DSN=smtp://<user>:<password>@lenouvel.me:465
> l'instance est créée avec `MAILER_DSN=null://null` : aucun email d'invitation ni de
> réinitialisation de mot de passe ne part (cf. `GuestAccountCreator`).

### `BROADCAST_SHARED_TOKEN` — diffusion admin multi-instances (#283)

Secret partagé, **identique sur les 7 instances**, qui authentifie les appels
service-to-service entre `ronan.lenouvel.me` (qui orchestre) et chaque autre
instance (endpoint interne `POST /internal/broadcast`, hors JWT utilisateur).

```bash
BROADCAST_SHARED_TOKEN=<généré une fois avec : openssl rand -hex 32>
BROADCAST_INSTANCE_NAME=<prenom> # ronan, yannick, coralie...
BROADCAST_ADMIN_EMAIL=<email du compte admin, pertinent uniquement sur ronan.lenouvel.me>
```

Généré automatiquement par `bin/deploy-all.sh --init` depuis
`BROADCAST_SHARED_TOKEN_PRESET` (et `BROADCAST_ADMIN_EMAIL_PRESET`) dans
`.secrets` (variables globales, comme `MAILER_DSN_PRESET`). Pour les
instances **déjà déployées avant cette feature**, ajouter manuellement les
3 lignes ci-dessus dans le `.env.local` de chacune (SSH), avec la **même**
valeur de `BROADCAST_SHARED_TOKEN` partout — un token qui diverge sur une
seule instance la rend injoignable depuis `ronan.lenouvel.me` (401 silencieux
côté orchestrateur, loggé en warning, sans bloquer les autres instances).

### `MAILER_DSN` — obligatoire pour les emails (dont la notif de fin de lot)

Par défaut `MAILER_DSN=null://null` (`.env`) : **aucun email n'est envoyé**. Les
Expand Down
12 changes: 12 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,15 @@ JWT_PASSPHRASE=
###> symfony/mailer ###
MAILER_DSN=null://null
###< symfony/mailer ###

###> app/broadcast ###
# Diffusion admin multi-instances (#283)
# Secret partagé entre les 7 instances — générer avec : openssl rand -hex 32
# Placer la valeur réelle dans .env.local (jamais commiter le secret)
BROADCAST_SHARED_TOKEN=
# Prénom de l'instance courante (ronan, yannick, coralie...) — utilisé pour
# s'auto-exclure de la boucle d'appels HTTP vers les autres instances
BROADCAST_INSTANCE_NAME=
# Email du compte admin autorisé à déclencher un broadcast depuis /admin/broadcast
BROADCAST_ADMIN_EMAIL=
###< app/broadcast ###
9 changes: 9 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,12 @@ JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=CHANGE_ME
JWT_TTL=3600

# Diffusion admin multi-instances (#283)
# Secret partagé — identique sur les 7 instances, généré une fois avec :
# openssl rand -hex 32
BROADCAST_SHARED_TOKEN=CHANGE_ME
# Prénom de cette instance (ronan, yannick, coralie...)
BROADCAST_INSTANCE_NAME=PRENOM
# Email du compte admin autorisé à déclencher un broadcast (uniquement pertinent sur ronan.lenouvel.me)
BROADCAST_ADMIN_EMAIL=CHANGE_ME
3 changes: 3 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
APP_ENV=test
KERNEL_CLASS='App\Kernel'
DATABASE_URL="mysql://root:root@127.0.0.1:3306/homecloud?serverVersion=mariadb-10.11.0&charset=utf8mb4"
BROADCAST_SHARED_TOKEN=test-broadcast-token-fixed
BROADCAST_INSTANCE_NAME=ronan
BROADCAST_ADMIN_EMAIL=admin@homecloud.test
9 changes: 9 additions & 0 deletions .github/avancement.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@

---

## ✅ Diffusion d'un message admin multi-instances (2026-07-23, #283, branche `feature/283-broadcast-message`)

- Ronan peut diffuser un message (maintenance, indisponibilité, mise à jour majeure) par email à tous les utilisateurs (propriétaires + invités) de toutes les instances déployées, ou une seule instance ciblée, en une seule action depuis `/admin/broadcast`.
- Architecture : chaque instance a sa propre DB isolée (pas de requête SQL centrale possible). L'instance `ronan.lenouvel.me` orchestre via un appel HTTP interne (`BroadcastOrchestrator` → `POST /internal/broadcast`) sur chaque autre instance, qui envoie ensuite ses propres emails localement (`BroadcastMailer::sendToAllUsers`). Secret partagé identique sur les 7 instances (`BROADCAST_SHARED_TOKEN`), authentification par `BroadcastTokenAuthenticator` sur un firewall dédié `broadcast_internal`, hors du firewall JWT `api` (structurellement incompatible avec un appel service-to-service).
- Pas de nouveau `ROLE_ADMIN` Symfony (aucun rôle différencié n'existe dans ce projet) : garde applicative `BroadcastAdminChecker` (whitelist par email `BROADCAST_ADMIN_EMAIL`), couvrant le cas où un guest serait un jour ajouté sur l'instance admin elle-même.
- Dry-run à tous les niveaux (service, orchestrateur, endpoint, command, formulaire admin) sans duplication de la logique d'envoi — en dry-run, l'orchestrateur ne fait aucun appel HTTP sortant.
- Canal email uniquement pour cette version — notification in-app laissée en ticket de suivi (#361).
- Tests : `BroadcastTargetProviderTest`, `BroadcastMailerTest` (dont guest inclus, email invalide skippé), `BroadcastOrchestratorTest` (ciblage, dry-run, échec partiel n'interrompt pas les autres), `BroadcastInternalControllerTest` (401 sans/mauvais token), `BroadcastSendCommandTest`, `BroadcastAdminCheckerTest`, `BroadcastAdminWebControllerTest` (403 non-admin).

## ✅ Stockage utilisé sur le dashboard (2026-07-23, #301, branche `feature/301-storage-used-dashboard`)

- `HomeController::index()` affichait un placeholder statique (`'Calcul à implémenter'`) à la place du poids réel de stockage.
Expand Down
8 changes: 7 additions & 1 deletion bin/deploy-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ for PRENOM in "${TARGETS[@]}"; do
if [[ -z "${MAILER_DSN_PRESET:-}" ]]; then
warn "MAILER_DSN_PRESET absent (.secrets ou .secrets.${PRENOM}) — l'instance n'enverra aucun email"
fi
if [[ -z "${BROADCAST_SHARED_TOKEN_PRESET:-}" ]]; then
warn "BROADCAST_SHARED_TOKEN_PRESET absent (.secrets) — le broadcast admin (#283) sera désactivé sur cette instance"
fi

DB_NAME="${SSH_USER}_${PRENOM}"
DB_USER="${SSH_USER}_${PRENOM}"
Expand All @@ -160,7 +163,10 @@ DATABASE_URL=${DATABASE_URL}
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=${JWT_PASSPHRASE}
MAILER_DSN=${MAILER_DSN_PRESET:-null://null}"
MAILER_DSN=${MAILER_DSN_PRESET:-null://null}
BROADCAST_SHARED_TOKEN=${BROADCAST_SHARED_TOKEN_PRESET:-}
BROADCAST_INSTANCE_NAME=${PRENOM}
BROADCAST_ADMIN_EMAIL=${BROADCAST_ADMIN_EMAIL_PRESET:-}"

if ssh ${SSH_KEY_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${SSH_HOST}" "set -e; mkdir -p ${DEPLOY_PATH}; cd ${DEPLOY_PATH}; git clone ${GIT_REPO} . && mkdir -p var/log && cat > .env.local <<'ENVEOF'
${ENV_LOCAL_CONTENT}
Expand Down
17 changes: 17 additions & 0 deletions config/broadcast_targets.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

// Miroir de .deploy-targets (racine du repo) — à maintenir manuellement en
// synchro à chaque nouvelle instance. Ne PAS lire .deploy-targets depuis le
// PHP applicatif : fichier shell, format non garanti, usage réservé à
// bin/deploy-all.sh.
return [
'ronan' => 'https://ronan.lenouvel.me',
'yannick' => 'https://yannick.lenouvel.me',
'coralie' => 'https://coralie.lenouvel.me',
'elea' => 'https://elea.lenouvel.me',
'corentin' => 'https://corentin.lenouvel.me',
'damien' => 'https://damien.lenouvel.me',
'baptiste' => 'https://baptiste.lenouvel.me',
];
9 changes: 9 additions & 0 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ security:
max_attempts: 5
interval: '15 minutes'

broadcast_internal:
pattern: ^/internal/broadcast
stateless: true
custom_authenticators:
- App\Security\BroadcastTokenAuthenticator

api:
pattern: ^/api
stateless: true
Expand Down Expand Up @@ -68,6 +74,9 @@ when@dev: &access_control
- { path: ^/api/reset-password, roles: PUBLIC_ACCESS }
- { path: ^/api/request-reset-password, roles: PUBLIC_ACCESS }
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
# Auth par secret partagé gérée par BroadcastTokenAuthenticator,
# pas par ROLE_USER JWT — doit précéder la règle catch-all ^/api.
- { path: ^/internal/broadcast, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: ROLE_USER }
# Partage par lien public : accessible sans compte, le secret est
# dans (selector, token). Doit précéder toute règle catch-all ^/.
Expand Down
7 changes: 7 additions & 0 deletions config/packages/test/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ security:
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure

broadcast_internal:
pattern: ^/internal/broadcast
stateless: true
custom_authenticators:
- App\Security\BroadcastTokenAuthenticator

api:
pattern: ^/api
stateless: true
Expand Down Expand Up @@ -52,6 +58,7 @@ security:
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api/v1/auth/login$, roles: PUBLIC_ACCESS }
- { path: ^/api/v1/auth/token/refresh$, roles: PUBLIC_ACCESS }
- { path: ^/internal/broadcast, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: ROLE_USER }
- { path: ^/login$, roles: PUBLIC_ACCESS }
- { path: ^/web/token$, roles: ROLE_USER }
Expand Down
8 changes: 8 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ services:
App\Interface\BatchCompletionNotifierInterface: '@App\Service\BatchCompletionNotifier'
App\Interface\ShareNotificationMailerInterface: '@App\Service\ShareNotificationMailer'
App\Interface\FolderZipArchiverInterface: '@App\Service\FolderZipArchiver'
App\Interface\BroadcastTargetProviderInterface: '@App\Service\BroadcastTargetProvider'
App\Interface\BroadcastMailerInterface: '@App\Service\BroadcastMailer'
App\Interface\BroadcastOrchestratorInterface: '@App\Service\BroadcastOrchestrator'

App\State\AlbumProcessor:
arguments:
Expand Down Expand Up @@ -83,6 +86,11 @@ services:
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
bind:
string $sharedToken: '%env(BROADCAST_SHARED_TOKEN)%'
string $adminEmail: '%env(BROADCAST_ADMIN_EMAIL)%'
string $currentInstance: '%env(BROADCAST_INSTANCE_NAME)%'
string $configPath: '%kernel.project_dir%/config/broadcast_targets.php'

# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
Expand Down
59 changes: 59 additions & 0 deletions src/Command/BroadcastSendCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

namespace App\Command;

use App\Interface\BroadcastOrchestratorInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

/**
* Diffuse un message admin (#283) à tous les utilisateurs de toutes les
* instances, ou d'une seule instance ciblée. Déclenchée manuellement ou par
* l'interface admin web (les deux délèguent à BroadcastOrchestratorInterface).
*/
#[AsCommand(name: 'app:broadcast:send', description: 'Diffuse un message admin à tous les utilisateurs des instances déployées')]
final class BroadcastSendCommand extends Command
{
public function __construct(
private readonly BroadcastOrchestratorInterface $orchestrator,
) {
parent::__construct();
}

protected function configure(): void
{
$this
->addOption('subject', null, InputOption::VALUE_REQUIRED, 'Sujet du message')
->addOption('body', null, InputOption::VALUE_REQUIRED, 'Corps du message (HTML autorisé)')
->addOption('instance', null, InputOption::VALUE_REQUIRED, 'Cibler une seule instance (défaut : toutes)')
->addOption('dry-run', null, InputOption::VALUE_NONE, "Simuler sans envoyer d'email réel");
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);

$results = $this->orchestrator->dispatch(
(string) $input->getOption('subject'),
(string) $input->getOption('body'),
$input->getOption('instance'),
(bool) $input->getOption('dry-run'),
);

foreach ($results as $instance => $success) {
if ($success) {
$io->writeln(sprintf('<info>%s</info> : OK', $instance));
} else {
$io->writeln(sprintf('<error>%s</error> : échec', $instance));
}
}

return Command::SUCCESS;
}
}
38 changes: 38 additions & 0 deletions src/Controller/Api/BroadcastInternalController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace App\Controller\Api;

use App\Interface\BroadcastMailerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

/**
* Endpoint interne inter-instances du broadcast admin (#283). Appelé par
* l'instance ronan.lenouvel.me sur chaque autre instance pour y déclencher
* l'envoi local (DB isolée par instance). Authentification par secret
* partagé via BroadcastTokenAuthenticator, hors du firewall JWT `api`.
*/
final class BroadcastInternalController extends AbstractController
{
public function __construct(
private readonly BroadcastMailerInterface $broadcastMailer,
) {}

#[Route('/internal/broadcast', name: 'internal_broadcast', methods: ['POST'])]
public function __invoke(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];

$sent = $this->broadcastMailer->sendToAllUsers(
(string) ($data['subject'] ?? ''),
(string) ($data['body'] ?? ''),
(bool) ($data['dryRun'] ?? false),
);

return new JsonResponse(['sent' => $sent]);
}
}
62 changes: 62 additions & 0 deletions src/Controller/Web/BroadcastAdminWebController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace App\Controller\Web;

use App\Dto\BroadcastMessageInput;
use App\Entity\User;
use App\Form\BroadcastMessageFormType;
use App\Interface\BroadcastOrchestratorInterface;
use App\Security\BroadcastAdminChecker;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

/**
* Interface admin de diffusion d'un message (maintenance/indisponibilité,
* #283) à tous les utilisateurs de toutes les instances, ou une instance
* ciblée. Réservée au compte admin (BroadcastAdminChecker) — pas de
* ROLE_ADMIN Symfony, cf. justification dans ce service.
*/
#[IsGranted('ROLE_USER')]
final class BroadcastAdminWebController extends AbstractController
{
public function __construct(
private readonly BroadcastAdminChecker $adminChecker,
private readonly BroadcastOrchestratorInterface $orchestrator,
) {}

#[Route('/admin/broadcast', name: 'app_broadcast_admin', methods: ['GET', 'POST'])]
public function __invoke(Request $request): Response
{
/** @var User $user */
$user = $this->getUser();

if (!$this->adminChecker->isAdmin($user)) {
throw $this->createAccessDeniedException("Réservé à l'administrateur.");
}

$input = new BroadcastMessageInput();
$form = $this->createForm(BroadcastMessageFormType::class, $input);
$form->handleRequest($request);

$results = null;

if ($form->isSubmitted() && $form->isValid()) {
$results = $this->orchestrator->dispatch(
$input->subject,
$input->body,
$input->targetInstance,
$input->dryRun,
);
}

return $this->render('web/broadcast_admin.html.twig', [
'form' => $form->createView(),
'results' => $results,
]);
}
}
21 changes: 21 additions & 0 deletions src/Dto/BroadcastMessageInput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final class BroadcastMessageInput
{
#[Assert\NotBlank(message: 'Le sujet est obligatoire.')]
public string $subject = '';

#[Assert\NotBlank(message: 'Le message est obligatoire.')]
public string $body = '';

/** null = toutes les instances */
public ?string $targetInstance = null;

public bool $dryRun = false;
}
Loading