src/Controller/WorkroomChatController.php line 195

Open in your IDE?
  1. <?php
  2. namespace App\Controller;
  3. use App\Entity\User;
  4. use App\Entity\Workroom;
  5. use App\Entity\WorkroomArena;
  6. use App\Entity\WorkroomChatMessage;
  7. use App\Repository\WorkroomArenaRepository;
  8. use App\Repository\WorkroomChatMessageRepository;
  9. use App\Repository\WorkroomRepository;
  10. use App\Security\Voter\WorkroomVoter;
  11. use Doctrine\ORM\EntityManagerInterface;
  12. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  13. use Symfony\Component\HttpFoundation\JsonResponse;
  14. use Symfony\Component\HttpFoundation\Request;
  15. use Symfony\Component\Mercure\HubInterface;
  16. use Symfony\Component\Mercure\Update;
  17. use Symfony\Component\Routing\Annotation\Route;
  18. /**
  19.  * Chat workroom natif (Phase 23) — remplace l'ancien chat-block CKEditor
  20.  * Cloud Services par une implémentation Symfony + Mercure SSE + Doctrine.
  21.  *
  22.  * Sécurité : voter `WorkroomVoter::WORKROOM_VIEW` (= membre du workroom)
  23.  * gardes toutes les routes. Anti-IDOR par UUID forgé.
  24.  *
  25.  * Realtime : on publie sur le topic Mercure `/workroom/{uuid}/chat` à
  26.  * chaque envoi de message + chaque heartbeat presence. Le browser est en
  27.  * écoute SSE sur ce topic et merge les messages dans la liste DOM.
  28.  *
  29.  * Routes :
  30.  *   - GET  /workroom/{uuid}/chat/messages    historique 50 derniers
  31.  *   - POST /workroom/{uuid}/chat/messages    envoie + publish Mercure
  32.  *   - DELETE /workroom/chat/messages/{id}    supprime un message (auteur uniquement)
  33.  *   - POST /workroom/{uuid}/chat/presence    heartbeat presence (publish ping)
  34.  */
  35. class WorkroomChatController extends AbstractController
  36. {
  37.     /** Garde-fou anti-spam — un client ne peut envoyer plus de messages
  38.      *  que ça par minute (FeatureRateLimiter au cas où, mais aussi ce cap
  39.      *  applicatif léger en cas d'abus de l'API direct). */
  40.     private const MAX_MESSAGE_LENGTH 4000;
  41.     public function __construct(
  42.         private readonly EntityManagerInterface $em,
  43.         private readonly WorkroomChatMessageRepository $messages,
  44.         private readonly WorkroomRepository $workrooms,
  45.         private readonly WorkroomArenaRepository $arenas,
  46.         private readonly HubInterface $hub,
  47.     ) {}
  48.     private function currentUser(): ?User
  49.     {
  50.         $u $this->getUser();
  51.         return $u instanceof User $u null;
  52.     }
  53.     /**
  54.      * Résout un UUID en Workroom OU WorkroomArena (chat polymorphique).
  55.      * Voter `WorkroomVoter::WORKROOM_VIEW` appliqué dans les deux cas — pour
  56.      * une arène, on vérifie le voter sur le workroom parent (être membre du
  57.      * workroom suffit pour voir les arènes qui en sont des forks).
  58.      */
  59.     private function loadContainer(string $uuid): Workroom|WorkroomArena|null
  60.     {
  61.         $w $this->workrooms->findOneBy(['uuid' => $uuid]);
  62.         if ($w) {
  63.             if (!$this->isGranted(WorkroomVoter::WORKROOM_VIEW$w)) return null;
  64.             return $w;
  65.         }
  66.         $arena $this->arenas->findOneBy(['uuid' => $uuid]);
  67.         if ($arena) {
  68.             $parent $arena->getWorkroom();
  69.             if (!$parent || !$this->isGranted(WorkroomVoter::WORKROOM_VIEW$parent)) return null;
  70.             return $arena;
  71.         }
  72.         return null;
  73.     }
  74.     /** @deprecated remplacé par loadContainer */
  75.     private function loadWorkroom(string $uuid): ?Workroom
  76.     {
  77.         $c $this->loadContainer($uuid);
  78.         return $c instanceof Workroom $c null;
  79.     }
  80.     /** Historique des 50 derniers messages (DESC). */
  81.     #[Route('/workroom/{uuid}/chat/messages'name'workroom_chat_list'methods: ['GET'], options: ['expose' => true])]
  82.     public function list(string $uuid): JsonResponse
  83.     {
  84.         $user $this->currentUser();
  85.         if (!$user) return new JsonResponse(['error' => 'unauthorized'], 401);
  86.         $container $this->loadContainer($uuid);
  87.         if (!$container) return new JsonResponse(['error' => 'forbidden'], 403);
  88.         $items $container instanceof WorkroomArena
  89.             $this->messages->findRecentForArena($container50)
  90.             : $this->messages->findRecentForWorkroom($container50);
  91.         return new JsonResponse([
  92.             'messages' => array_map(static fn (WorkroomChatMessage $m) => $m->toArray(), $items),
  93.             'topic' => $this->topicForContainer($container),
  94.         ]);
  95.     }
  96.     /** Envoie un message + publie sur Mercure pour les autres clients. */
  97.     #[Route('/workroom/{uuid}/chat/messages'name'workroom_chat_send'methods: ['POST'], options: ['expose' => true])]
  98.     public function send(string $uuidRequest $request): JsonResponse
  99.     {
  100.         $user $this->currentUser();
  101.         if (!$user) return new JsonResponse(['error' => 'unauthorized'], 401);
  102.         $container $this->loadContainer($uuid);
  103.         if (!$container) return new JsonResponse(['error' => 'forbidden'], 403);
  104.         $body json_decode($request->getContent(), true) ?? [];
  105.         $text trim((string) ($body['text'] ?? ''));
  106.         if ($text === '') return new JsonResponse(['error' => 'text_required'], 400);
  107.         if (mb_strlen($text) > self::MAX_MESSAGE_LENGTH) {
  108.             return new JsonResponse(['error' => 'text_too_long''max' => self::MAX_MESSAGE_LENGTH], 400);
  109.         }
  110.         $msg = (new WorkroomChatMessage())
  111.             ->setUser($user)
  112.             ->setText($text);
  113.         if ($container instanceof WorkroomArena) {
  114.             $msg->setWorkroomArena($container);
  115.         } else {
  116.             $msg->setWorkroom($container);
  117.         }
  118.         $this->em->persist($msg);
  119.         $this->em->flush();
  120.         try {
  121.             $this->hub->publish(new Update(
  122.                 $this->topicForContainer($container),
  123.                 json_encode([
  124.                     'type' => 'message',
  125.                     'message' => $msg->toArray(),
  126.                 ], JSON_UNESCAPED_UNICODE),
  127.             ));
  128.         } catch (\Throwable) {}
  129.         return new JsonResponse(['message' => $msg->toArray()], 201);
  130.     }
  131.     /** Suppression d'un message (auteur uniquement). */
  132.     #[Route('/workroom/chat/messages/{id}'name'workroom_chat_delete'methods: ['DELETE'], requirements: ['id' => '\d+'], options: ['expose' => true])]
  133.     public function delete(int $id): JsonResponse
  134.     {
  135.         $user $this->currentUser();
  136.         if (!$user) return new JsonResponse(['error' => 'unauthorized'], 401);
  137.         $msg $this->messages->find($id);
  138.         if (!$msg) return new JsonResponse(['error' => 'not_found'], 404);
  139.         if ($msg->getUser()?->getId() !== $user->getId()) {
  140.             return new JsonResponse(['error' => 'forbidden'], 403);
  141.         }
  142.         // Vérif permission selon le container du message (workroom direct OU
  143.         // arène — pour l'arène on remonte au workroom parent).
  144.         $workroom $msg->getWorkroom();
  145.         $arena $msg->getWorkroomArena();
  146.         $checkWorkroom $workroom ?? $arena?->getWorkroom();
  147.         if ($checkWorkroom && !$this->isGranted(WorkroomVoter::WORKROOM_VIEW$checkWorkroom)) {
  148.             return new JsonResponse(['error' => 'forbidden'], 403);
  149.         }
  150.         $messageId $msg->getId();
  151.         $topic $workroom $this->topicForContainer($workroom)
  152.                 : ($arena $this->topicForContainer($arena) : null);
  153.         $this->em->remove($msg);
  154.         $this->em->flush();
  155.         if ($topic) {
  156.             try {
  157.                 $this->hub->publish(new Update($topicjson_encode([
  158.                     'type' => 'delete',
  159.                     'messageId' => $messageId,
  160.                 ], JSON_UNESCAPED_UNICODE)));
  161.             } catch (\Throwable) {}
  162.         }
  163.         return new JsonResponse(['deleted' => true]);
  164.     }
  165.     /**
  166.      * Heartbeat presence — le client appelle cette route toutes les 15s
  167.      * tant que le panel chat est ouvert. Le serveur publish un ping sur
  168.      * Mercure ; les autres clients en déduisent qui est en ligne (timeout
  169.      * 45s côté browser = "user disparu de la presence list").
  170.      *
  171.      * On ne stocke RIEN en DB (presence = éphémère, pure Mercure).
  172.      */
  173.     #[Route('/workroom/{uuid}/chat/presence'name'workroom_chat_presence'methods: ['POST'], options: ['expose' => true])]
  174.     public function presence(string $uuid\Psr\Cache\CacheItemPoolInterface $cache): JsonResponse
  175.     {
  176.         $user $this->currentUser();
  177.         if (!$user) return new JsonResponse(['error' => 'unauthorized'], 401);
  178.         $container $this->loadContainer($uuid);
  179.         if (!$container) return new JsonResponse(['error' => 'forbidden'], 403);
  180.         // Persist en cache.app pour qu'un autre user puisse fetcher la liste live
  181.         // sans dépendre de SSE (filet de sécurité indépendant). Pattern PSR-6
  182.         // direct (getItem/set/save) pour pouvoir update en-place.
  183.         $cacheKey self::presenceCacheKey($container);
  184.         try {
  185.             $now time();
  186.             $item $cache->getItem($cacheKey);
  187.             $map $item->isHit() ? (array) $item->get() : [];
  188.             $map[$user->getId()] = [
  189.                 'id' => $user->getId(),
  190.                 'firstName' => $user->getFirstName(),
  191.                 'lastName' => $user->getLastName(),
  192.                 'at' => $now,
  193.             ];
  194.             foreach ($map as $uid => $entry) {
  195.                 if (($entry['at'] ?? 0) < $now 20) unset($map[$uid]);
  196.             }
  197.             $item->set($map);
  198.             $item->expiresAfter(60);
  199.             $cache->save($item);
  200.         } catch (\Throwable) {}
  201.         try {
  202.             $this->hub->publish(new Update($this->topicForContainer($container), json_encode([
  203.                 'type' => 'presence',
  204.                 'user' => [
  205.                     'id' => $user->getId(),
  206.                     'firstName' => $user->getFirstName(),
  207.                     'lastName' => $user->getLastName(),
  208.                 ],
  209.                 'at' => time(),
  210.             ], JSON_UNESCAPED_UNICODE)));
  211.         } catch (\Throwable) {}
  212.         return new JsonResponse(['ok' => true]);
  213.     }
  214.     /**
  215.      * GET /workroom/{uuid}/chat/presence — liste des users actuellement en
  216.      * ligne pour ce workroom/arena. Filet de sécurité indépendant de SSE :
  217.      * le frontend poll cet endpoint toutes les 5-10s pour repeupler la
  218.      * presence list si Mercure échoue à délivrer les events temps réel.
  219.      */
  220.     #[Route('/workroom/{uuid}/chat/presence'name'workroom_chat_presence_list'methods: ['GET'], options: ['expose' => true])]
  221.     public function presenceList(string $uuid\Psr\Cache\CacheItemPoolInterface $cache): JsonResponse
  222.     {
  223.         $user $this->currentUser();
  224.         if (!$user) return new JsonResponse(['error' => 'unauthorized'], 401);
  225.         $container $this->loadContainer($uuid);
  226.         if (!$container) return new JsonResponse(['error' => 'forbidden'], 403);
  227.         $now time();
  228.         $users = [];
  229.         try {
  230.             $item $cache->getItem(self::presenceCacheKey($container));
  231.             $map $item->isHit() ? (array) $item->get() : [];
  232.             foreach ($map as $entry) {
  233.                 if (($entry['at'] ?? 0) >= $now 20) {
  234.                     $users[] = [
  235.                         'id' => $entry['id'],
  236.                         'firstName' => $entry['firstName'] ?? '',
  237.                         'lastName' => $entry['lastName'] ?? '',
  238.                     ];
  239.                 }
  240.             }
  241.         } catch (\Throwable) {}
  242.         return new JsonResponse(['users' => $users]);
  243.     }
  244.     private static function presenceCacheKey(Workroom|WorkroomArena $c): string
  245.     {
  246.         return 'presence_state_'.$c->getUuid();
  247.     }
  248.     /**
  249.      * Topic Mercure du chat. Distinct entre workroom et arène pour ne pas
  250.      * mélanger les conversations (une arène = fork avec son propre fil).
  251.      */
  252.     private function topicForContainer(Workroom|WorkroomArena $c): string
  253.     {
  254.         $prefix $c instanceof WorkroomArena '/arena/' '/workroom/';
  255.         return $prefix $c->getUuid() . '/chat';
  256.     }
  257. }