src/Controller/BetaController.php line 41

Open in your IDE?
  1. <?php
  2. namespace App\Controller;
  3. use App\Entity\Users;
  4. use App\Form\BetaAccessRequestType;
  5. use App\Repository\UsersRepository;
  6. use Doctrine\ORM\EntityManagerInterface;
  7. use App\Service\BetaAccessLink;
  8. use App\Service\BetaAccessMailer;
  9. use App\Service\BetaCookie;
  10. use App\Service\BetaRequestNotifier;
  11. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  12. use Symfony\Component\HttpFoundation\Request;
  13. use Symfony\Component\HttpFoundation\Response;
  14. use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
  15. use Symfony\Component\Routing\Annotation\Route;
  16. use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
  17. class BetaController extends AbstractController
  18. {
  19.     private const TOKEN_TTL = 1800; // 30 minutes
  20.     /**
  21.      * Expéditeur des emails partant de SELECT.
  22.      *
  23.      * Le nom affiché est libre ; l'adresse, elle, doit rester un expéditeur
  24.      * validé côté Mailjet — `contact@lelorrain.fr` y est actif et déclaré
  25.      * transactionnel. En changer demande de la déclarer d'abord chez Mailjet.
  26.      */
  27.     private const SENDER_EMAIL = 'contact@lelorrain.fr';
  28.     private const SENDER_NAME  = 'LE LORRAIN SELECT';
  29.     public function __construct(
  30.         private UsersRepository $usersRepository,
  31.         private BetaCookie $betaCookie,
  32.         private BetaAccessMailer $betaAccessMailer
  33.     ) {}
  34.     #[Route('/beta-acces', name: 'beta_request')]
  35.     public function request(Request $request): Response
  36.     {
  37.         $sent = false;
  38.         $error = null;
  39.         if ($request->isMethod('POST')) {
  40.             $email = trim($request->request->get('email', ''));
  41.             if ($email === '') {
  42.                 $error = 'Merci de saisir votre adresse email.';
  43.             } elseif (!$this->hasBetaAccess($email)) {
  44.                 // Message volontairement générique : il ne révèle pas si l'adresse
  45.                 // existe ni si elle dispose d'un accès.
  46.                 //
  47.                 // Transmis en variable et non en flash : base.html.twig inclut
  48.                 // _flash.html.twig AVANT le bloc body, et `app.flashes` consomme le
  49.                 // sac — un flash serait donc affiché hors de la carte, sous l'en-tête
  50.                 // fixe, et resterait invisible.
  51.                 $error = "L'accès à LE LORRAIN SELECT n'est pas possible avec cette adresse email. "
  52.                     . 'Pour demander un accès, contactez nos équipes via le formulaire de contact '
  53.                     . 'en bas de cette page.';
  54.             } else {
  55.                 $token = bin2hex(random_bytes(32));
  56.                 $request->getSession()->set('subscription', [
  57.                     'email'       => $email,
  58.                     'token'       => $token,
  59.                     'is_verified' => false,
  60.                     'expires_at'  => time() + self::TOKEN_TTL,
  61.                 ]);
  62.                 $link = $this->generateUrl(
  63.                     'confirm_email',
  64.                     ['token' => $token],
  65.                     UrlGeneratorInterface::ABSOLUTE_URL
  66.                 );
  67.                 $this->sendEmail(
  68.                     $email,
  69.                     $email,
  70.                     'Accès à LE LORRAIN SELECT – Confirmation de votre email',
  71.                     self::SENDER_EMAIL,
  72.                     self::SENDER_NAME,
  73.                     $this->buildEmailBody($link)
  74.                 );
  75.                 $sent = true;
  76.             }
  77.         }
  78.         return $this->render('security/beta_request.html.twig', [
  79.             'sent'  => $sent,
  80.             'error' => $error,
  81.         ]);
  82.     }
  83.     /**
  84.      * Dépôt d'une demande d'accès par une personne qui n'en dispose pas.
  85.      *
  86.      * Crée un compte marqué « demande en attente », inutilisable en l'état :
  87.      * non vérifié, sans flag bêta, avec un mot de passe aléatoire. Un
  88.      * administrateur tranche ensuite depuis /admin/utilisateurs?filtre=demande-beta.
  89.      *
  90.      * `BetaRequestNotifier` prévient l'équipe par email : sans cela, rien ne
  91.      * signalait la demande et elle pouvait dormir indéfiniment en base.
  92.      */
  93.     #[Route('/beta-acces/demande', name: 'beta_access_request', methods: ['GET', 'POST'])]
  94.     public function accessRequest(
  95.         Request $request,
  96.         EntityManagerInterface $em,
  97.         UserPasswordHasherInterface $passwordHasher,
  98.         BetaRequestNotifier $notifier
  99.     ): Response {
  100.         $form = $this->createForm(BetaAccessRequestType::class);
  101.         $form->handleRequest($request);
  102.         $sent = false;
  103.         if ($form->isSubmitted() && $form->isValid()) {
  104.             $data = $form->getData();
  105.             $email = trim((string) $data['email']);
  106.             $user = $this->usersRepository->findOneBy(['email' => $email]);
  107.             $existingAccount = $user !== null;
  108.             if ($user === null) {
  109.                 $user = new Users();
  110.                 $user->setEmail($email);
  111.                 $user->setFirstname(trim((string) $data['firstname']));
  112.                 $user->setLastname(trim((string) $data['lastname']));
  113.                 $user->setCompanyName(trim((string) $data['companyName']));
  114.                 // Colonnes NOT NULL non demandées dans ce formulaire
  115.                 $user->setAddress('');
  116.                 $user->setZipcode('');
  117.                 $user->setCity('');
  118.                 // Le compte n'est pas utilisable : ni vérifié, ni bêta. Le mot de
  119.                 // passe aléatoire empêche toute connexion tant qu'il n'est pas
  120.                 // réinitialisé par la personne ou redéfini par un administrateur.
  121.                 $user->setIsVerified(false);
  122.                 $user->setPassword($passwordHasher->hashPassword($user, bin2hex(random_bytes(16))));
  123.                 $em->persist($user);
  124.             }
  125.             // Compte déjà connu : on marque simplement la demande, sans rien écraser
  126.             $user->registerBetaRequest();
  127.             $em->flush();
  128.             // La demande est enregistrée quoi qu'il arrive : un échec d'envoi
  129.             // est journalisé par le service, jamais opposé au visiteur.
  130.             $notifier->notify($user, $existingAccount);
  131.             $sent = true;
  132.         }
  133.         return $this->render('security/beta_access_request.html.twig', [
  134.             'requestForm' => $form->createView(),
  135.             'sent'        => $sent,
  136.         ]);
  137.     }
  138.     #[Route('/confirm/{token}', name: 'confirm_email')]
  139.     public function confirm(string $token, Request $request): Response
  140.     {
  141.         // Le visiteur possède déjà un accès valide : le lien a donc déjà été
  142.         // consommé. On l'envoie sur l'outil plutôt que de lui opposer une erreur.
  143.         if ($this->betaCookie->isValid($request)) {
  144.             return $this->redirectToRoute('main');
  145.         }
  146.         $subscription = $request->getSession()->get('subscription');
  147.         if (!$subscription) {
  148.             $this->addFlash('danger', 'Votre demande a expiré ou a été faite depuis un autre navigateur. Merci de redemander un lien.');
  149.             return $this->redirectToRoute('beta_request');
  150.         }
  151.         if (!hash_equals($subscription['token'], $token)) {
  152.             $this->addFlash('danger', 'Lien de connexion invalide.');
  153.             return $this->redirectToRoute('beta_request');
  154.         }
  155.         if ($subscription['expires_at'] < time()) {
  156.             $request->getSession()->remove('subscription');
  157.             $this->addFlash('danger', 'Ce lien a expiré. Merci d\'en demander un nouveau.');
  158.             return $this->redirectToRoute('beta_request');
  159.         }
  160.         // L'accès a pu être retiré entre l'envoi du lien et son ouverture
  161.         if (!$this->hasBetaAccess($subscription['email'])) {
  162.             $request->getSession()->remove('subscription');
  163.             $this->addFlash('danger', "Cette adresse email n'a plus accès à la bêta de LE LORRAIN SELECT.");
  164.             return $this->redirectToRoute('beta_request');
  165.         }
  166.         // Jeton à usage unique : on le retire de la session dès qu'il a servi.
  167.         // Rejouer le même lien ne redonnera plus l'accès — seul le cookie posé
  168.         // ci-dessous fait foi, pour la durée définie par BetaCookie::LIFETIME.
  169.         $request->getSession()->remove('subscription');
  170.         // Le cookie porte l'email signé : il identifie la personne pour les
  171.         // indicateurs SELECT et n'est pas falsifiable.
  172.         $response = $this->redirectToRoute('main');
  173.         $response->headers->setCookie($this->betaCookie->create($subscription['email'], $request));
  174.         return $response;
  175.     }
  176.     /**
  177.      * Accès direct depuis l'email annonçant la validation de la demande.
  178.      *
  179.      * Le jeton signé porte déjà l'adresse : il n'y a donc rien à ressaisir et
  180.      * aucun second email à envoyer — le bouton du message ouvre l'outil.
  181.      *
  182.      * Le lien ne se périme pas, conformément à ce que promet l'email. Le
  183.      * contrôle réel est le flag `beta`, revérifié ici : un accès retiré depuis
  184.      * le backoffice rend le lien inopérant sur-le-champ.
  185.      */
  186.     #[Route('/acces-select/{token}', name: 'beta_direct_access', methods: ['GET'])]
  187.     public function directAccess(string $token, Request $request, BetaAccessLink $accessLink): Response
  188.     {
  189.         $email = $accessLink->readEmail($token);
  190.         if ($email === null) {
  191.             $this->addFlash('danger', "Ce lien d'accès est invalide. Merci de saisir votre adresse email ci-dessous.");
  192.             return $this->redirectToRoute('beta_request');
  193.         }
  194.         if (!$this->hasBetaAccess($email)) {
  195.             $this->addFlash('danger', "Cette adresse email n'a pas (ou plus) accès à LE LORRAIN SELECT.");
  196.             return $this->redirectToRoute('beta_request');
  197.         }
  198.         $response = $this->redirectToRoute('main');
  199.         $response->headers->setCookie($this->betaCookie->create($email, $request));
  200.         return $response;
  201.     }
  202.     private function hasBetaAccess(string $email): bool
  203.     {
  204.         $user = $this->usersRepository->findOneBy(['email' => $email]);
  205.         return $user !== null && $user->isBeta();
  206.     }
  207.     private function buildEmailBody(string $link): string
  208.     {
  209.         // En-tête logo mutualisé avec BetaAccessMailer : une seule définition,
  210.         // une seule URL à changer le jour où le logo bouge.
  211.         return '<html><body style="font-family:Arial,Helvetica,sans-serif;color:#212529;line-height:1.5;">'
  212.             . $this->betaAccessMailer->buildLogoHeader()
  213.             . '<p>Bonjour,</p>'
  214.             . '<p>Vous avez demandé à accéder à la version bêta de LE LORRAIN SELECT.</p>'
  215.             . '<p>Pour sécuriser votre accès, merci de confirmer votre adresse email en cliquant sur le lien ci-dessous :</p>'
  216.             . '<p><a href="' . $link . '" style="display:inline-block;padding:12px 24px;background:#0d6efd;color:#fff;text-decoration:none;border-radius:6px;font-weight:bold;">Confirmer mon adresse email</a></p>'
  217.             . '<p>Ce lien est valable <strong>30 minutes</strong>, ne fonctionne qu\'<strong>une seule fois</strong>, '
  218.             . 'et doit être ouvert depuis le navigateur où vous avez fait la demande.</p>'
  219.             . '<p>Il vous ouvrira ensuite l\'accès pendant <strong>24 heures</strong>.</p>'
  220.             . '<p>Si vous n\'êtes pas à l\'origine de cette demande, vous pouvez ignorer cet email.</p>'
  221.             . '<p>À très bientôt sur LE LORRAIN SELECT,<br>L\'équipe LE LORRAIN SELECT</p>'
  222.             . '</body></html>';
  223.     }
  224.     private function sendEmail(string $to, string $toTxt, string $subject, string $from, string $fromTxt, string $message): bool
  225.     {
  226.         $apiKey    = 'a9b94b25be48de689d7bbef6386dbd19';
  227.         $apiSecret = 'be54ed1f3e34a2e6dc6f8b01634f81d3';
  228.         $data = [
  229.             'Messages' => [[
  230.                 'From' => ['Email' => $from, 'Name' => $fromTxt],
  231.                 'To'   => [['Email' => $to, 'Name' => $toTxt]],
  232.                 'Subject'  => $subject,
  233.                 'TextPart' => strip_tags($message),
  234.                 'HTMLPart' => $message,
  235.             ]],
  236.         ];
  237.         $ch = curl_init('https://api.mailjet.com/v3.1/send');
  238.         curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  239.         curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
  240.         curl_setopt($ch, CURLOPT_USERPWD, $apiKey . ':' . $apiSecret);
  241.         curl_setopt($ch, CURLOPT_POST, true);
  242.         curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
  243.         curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
  244.         $response = curl_exec($ch);
  245.         $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  246.         curl_close($ch);
  247.         if ($httpCode === 200 && $response) {
  248.             $decoded = json_decode($response, true);
  249.             return isset($decoded['Messages'][0]['Status']) && $decoded['Messages'][0]['Status'] === 'success';
  250.         }
  251.         return false;
  252.     }
  253. }