From da8d15659f503b7ceac9a729fd0525ddf3c05585 Mon Sep 17 00:00:00 2001 From: Florian ALEXANDRE Date: Fri, 15 Dec 2023 14:56:52 +0100 Subject: [PATCH 01/12] feat: #108625 - migrate emailContentProtectionBundle to ibexa version --- README.md | 26 +++- bundle/Command/CleanTokenCommand.php | 66 ++++++++ bundle/Entity/ProtectedAccess.php | 41 ++++- bundle/Entity/ProtectedTokenStorage.php | 114 ++++++++++++++ bundle/Form/ProtectedAccessType.php | 10 +- .../Form/RequestEmailProtectedAccessType.php | 48 ++++++ bundle/Listener/EmailProvided.php | 144 ++++++++++++++++++ bundle/Listener/PreContentView.php | 71 +++++++-- .../ProtectedTokenStorageRepository.php | 45 ++++++ bundle/Resources/config/services.yaml | 9 ++ .../translations/ezprotectedcontent.en.yml | 10 ++ .../translations/ezprotectedcontent.fr.yml | 10 ++ .../admin/tabs/protected_content.html.twig | 8 + composer.json | 3 +- 14 files changed, 579 insertions(+), 26 deletions(-) create mode 100644 bundle/Command/CleanTokenCommand.php create mode 100644 bundle/Entity/ProtectedTokenStorage.php create mode 100644 bundle/Form/RequestEmailProtectedAccessType.php create mode 100644 bundle/Listener/EmailProvided.php create mode 100644 bundle/Repository/ProtectedTokenStorageRepository.php diff --git a/README.md b/README.md index 6f75d68..8460fee 100644 --- a/README.md +++ b/README.md @@ -17,20 +17,30 @@ Documentation is available in this repository via `.md` files but also packaged A bundle that provides quick password protection on Contents. -## How does it work? +# How it works Allows you to add 1 on N password on a Content in the Admin UI. +Once a protection is set, the Content becomes Protected. +In this situation you can have 3 new variables in the view full +- canReadProtectedContent (always) +- requestProtectedContentPasswordForm (if content is protected by password) +- requestProtectedContentEmailForm (if content is protected with email verification) -Once a Password is set, the Content becomes Protected. In this situation you will have 2 new variables in the view full. Allowing you do: - ```twig -

{{ ibexa_content_name(content) }}

+

{{ ez_content_name(content) }}

{% if not canReadProtectedContent %} -

This content has been protected by a password

-
- {{ form(requestProtectedContentPasswordForm) }} -
+ {% if requestProtectedContentPasswordForm is defined %} +

This content has been protected by a password

+
+ {{ form(requestProtectedContentPasswordForm) }} +
+ {% elseif requestProtectedContentEmailForm is defined %} +

This content has been protected by an email verification

+
+ {{ form(requestProtectedContentEmailForm) }} +
+ {% endif %} {% else %} {% for field in content.fieldsByLanguage(language|default(null)) %}

{{ field.fieldDefIdentifier }}

diff --git a/bundle/Command/CleanTokenCommand.php b/bundle/Command/CleanTokenCommand.php new file mode 100644 index 0000000..c48b094 --- /dev/null +++ b/bundle/Command/CleanTokenCommand.php @@ -0,0 +1,66 @@ +entityManager = $entityManager; + } + + protected function configure(): void + { + $this + ->setName('novaezprotectedcontent:cleantoken') + ->setDescription('Remove expired token in the DB'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output); + + /** @var ProtectedTokenStorageRepository $protectedTokenStorageRepository */ + $protectedTokenStorageRepository = $this->entityManager->getRepository(ProtectedTokenStorage::class); + + $entities = $protectedTokenStorageRepository->findExpired(); + + foreach ($entities as $entity) { + $this->entityManager->remove($entity); + } + + $this->entityManager->flush(); + + $io->success(sprintf('%d entities deleted', count($entities))); + $io->success('Done.'); + } +} diff --git a/bundle/Entity/ProtectedAccess.php b/bundle/Entity/ProtectedAccess.php index 1806a13..00fc4b2 100644 --- a/bundle/Entity/ProtectedAccess.php +++ b/bundle/Entity/ProtectedAccess.php @@ -40,8 +40,7 @@ class ProtectedAccess implements ContentInterface /** * @var string * - * @ORM\Column(type="string", length=255, nullable=false) - * @Assert\NotBlank() + * @ORM\Column(type="string", length=255, nullable=true) * @Assert\Length(max=255) */ protected $password; @@ -53,6 +52,13 @@ class ProtectedAccess implements ContentInterface */ protected $enabled; + /** + * @var bool + * + * @ORM\Column(type="boolean", nullable=false) + */ + protected $asEmail = false; + /** * @var bool * @@ -60,6 +66,13 @@ class ProtectedAccess implements ContentInterface */ protected $protectChildren; + /** + * @var string + * + * @ORM\Column(type="string", nullable=true) + */ + protected $emailMessage; + public function __construct() { $this->enabled = true; @@ -78,12 +91,24 @@ public function setId(int $id): self return $this; } + public function getAsEmail(): bool + { + return $this->asEmail ?? false; + } + + public function setAsEmail(bool $asEmail): self + { + $this->asEmail = $asEmail; + + return $this; + } + public function getPassword(): string { return $this->password ?? ''; } - public function setPassword(string $password): self + public function setPassword(?string $password): self { $this->password = $password; @@ -111,4 +136,14 @@ public function setProtectChildren(bool $protectChildren): void { $this->protectChildren = $protectChildren; } + + public function getEmailMessage(): ?string + { + return $this->emailMessage; + } + + public function setEmailMessage(string $emailMessage): void + { + $this->emailMessage = $emailMessage; + } } diff --git a/bundle/Entity/ProtectedTokenStorage.php b/bundle/Entity/ProtectedTokenStorage.php new file mode 100644 index 0000000..0b692cf --- /dev/null +++ b/bundle/Entity/ProtectedTokenStorage.php @@ -0,0 +1,114 @@ +id; + } + + public function setId(int $id): void + { + $this->id = $id; + } + + public function getCreated(): DateTime + { + return $this->created; + } + + public function setCreated(DateTime $created): void + { + $this->created = $created; + } + + public function getToken(): string + { + return $this->token; + } + + public function setToken(string $token): void + { + $this->token = $token; + } + + public function getMail(): string + { + return $this->mail; + } + + public function setMail(string $mail): void + { + $this->mail = $mail; + } + + public function getContentId(): int + { + return $this->content_id; + } + + public function setContentId(int $content_id): void + { + $this->content_id = $content_id; + } +} diff --git a/bundle/Form/ProtectedAccessType.php b/bundle/Form/ProtectedAccessType.php index 882cdd1..426f7fd 100644 --- a/bundle/Form/ProtectedAccessType.php +++ b/bundle/Form/ProtectedAccessType.php @@ -18,6 +18,7 @@ use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\HiddenType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -33,7 +34,14 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ['label' => 'tab.table.th.children_protection', 'required' => false] ) ->add('enabled', CheckboxType::class, ['label' => 'tab.table.th.enabled', 'required' => false]) - ->add('password', TextType::class, ['required' => true, 'label' => 'tab.table.th.password']); + ->add('password', TextType::class, ['required' => false, 'label' => 'tab.table.th.password']) + ->add('asEmail', CheckboxType::class, ['label' => 'tab.table.th.as_email', 'required' => false]) + ->add('emailMessage', TextareaType::class, [ + 'label' => 'tab.table.th.message_email', + 'help' => 'mail.help_message', + 'required' => false, + ]) + ; } public function configureOptions(OptionsResolver $resolver): void diff --git a/bundle/Form/RequestEmailProtectedAccessType.php b/bundle/Form/RequestEmailProtectedAccessType.php new file mode 100644 index 0000000..44b459c --- /dev/null +++ b/bundle/Form/RequestEmailProtectedAccessType.php @@ -0,0 +1,48 @@ +add( + 'email', + EmailType::class, + [ + 'required' => true, + 'label' => 'tab.table.th.email', + ] + ); + $builder->add('content_id', HiddenType::class); + $builder->add('submit', SubmitType::class); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults( + [ + 'translation_domain' => 'ezprotectedcontent', + ] + ); + } +} diff --git a/bundle/Listener/EmailProvided.php b/bundle/Listener/EmailProvided.php new file mode 100644 index 0000000..17e334f --- /dev/null +++ b/bundle/Listener/EmailProvided.php @@ -0,0 +1,144 @@ +formFactory = $formFactory; + $this->mailer = $mailer; + $this->entityManager = $entityManager; + $this->translator = $translator; + $this->parameterBag = $parameterBag; + $this->messageInstance = new Swift_Message(); + } + + public function onKernelRequest(RequestEvent $event): void + { + if (!$event->isMasterRequest()) { + return; + } + $form = $this->formFactory->create(RequestEmailProtectedAccessType::class); + + $request = $event->getRequest(); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $data = $form->getData(); + $contentId = intval($data['content_id']); + $token = Uuid::uuid4()->toString(); + $access = new ProtectedTokenStorage(); + + $access->setMail($data['email']); + $access->setContentId($contentId); + $access->setCreated(new DateTime()); + $access->setToken($token); + + $this->entityManager->persist($access); + $this->entityManager->flush(); + + $currentUrl = sprintf( + '%s://%s%s%s', + $request->getScheme(), + $request->getHost(), + $request->getBaseUrl(), + $request->getRequestUri() + ); + $accessUrl = $currentUrl.'?mail='.$data['email'].'&token='.$token; + $this->sendMail($contentId, $data['email'], $accessUrl); + $response = new RedirectResponse($request->getRequestUri().'?waiting_validation='.$data['email']); + $response->setPrivate(); + $event->setResponse($response); + } + } + + /** + * @throws Exception + */ + private function sendMail(int $contentId, string $receiver, string $link): void + { + /** @var ProtectedAccess $protectedAccess */ + $protectedAccess = $this->entityManager->getRepository(ProtectedAccess::class) + ->findOneBy(['contentId' => $contentId]); + + $mailLink = "".$this->translator->trans('mail.link', [], 'ezprotectedcontent').''; + $bodyMessage = str_replace('{{ url }}', $mailLink, $protectedAccess->getEmailMessage()); + + $message = $this->messageInstance + ->setSubject($this->translator->trans('mail.subject', [], 'ezprotectedcontent')) + ->setFrom($this->parameterBag->get('default_sender_email')) + ->setTo($receiver) + ->setContentType('text/html') + ->setBody( + $bodyMessage + ); + + try { + $this->mailer->send($message); + } catch (Exception $exception) { + throw new Exception(sprintf(self::SENDMAIL_ERROR, $receiver)); + } + } +} diff --git a/bundle/Listener/PreContentView.php b/bundle/Listener/PreContentView.php index cbbc6a4..242a631 100644 --- a/bundle/Listener/PreContentView.php +++ b/bundle/Listener/PreContentView.php @@ -18,8 +18,10 @@ use Ibexa\Core\MVC\Symfony\Event\PreContentViewEvent; use Ibexa\Core\MVC\Symfony\View\ContentView; use Novactive\Bundle\eZProtectedContentBundle\Entity\ProtectedAccess; +use Novactive\Bundle\eZProtectedContentBundle\Form\RequestEmailProtectedAccessType; use Novactive\Bundle\eZProtectedContentBundle\Form\RequestProtectedAccessType; use Novactive\Bundle\eZProtectedContentBundle\Repository\ProtectedAccessRepository; +use Novactive\Bundle\eZProtectedContentBundle\Repository\ProtectedTokenStorageRepository; use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\HttpFoundation\RequestStack; @@ -35,6 +37,11 @@ class PreContentView */ private $protectedAccessRepository; + /** + * @var ProtectedAccessRepository + */ + private $protectedTokenStorageRepository; + /** * @var FormFactoryInterface */ @@ -48,11 +55,13 @@ class PreContentView public function __construct( PermissionResolver $permissionResolver, ProtectedAccessRepository $protectedAccessRepository, + ProtectedTokenStorageRepository $protectedTokenStorageRepository, FormFactoryInterface $factory, RequestStack $requestStack ) { $this->permissionResolver = $permissionResolver; $this->protectedAccessRepository = $protectedAccessRepository; + $this->protectedTokenStorageRepository = $protectedTokenStorageRepository; $this->formFactory = $factory; $this->requestStack = $requestStack; } @@ -87,18 +96,37 @@ public function onPreContentView(PreContentViewEvent $event): void $canRead = $this->permissionResolver->canUser('private_content', 'read', $content); if (!$canRead) { - $cookies = $this->requestStack->getCurrentRequest()->cookies; - foreach ($cookies as $name => $value) { - if (PasswordProvided::COOKIE_PREFIX !== substr($name, 0, \strlen(PasswordProvided::COOKIE_PREFIX))) { - continue; + $request = $this->requestStack->getCurrentRequest(); + + if ( + $request->query->has('mail') + && $request->query->has('token') + && !$request->query->has('waiting_validation') + ) { + $unexpiredToken = $this->protectedTokenStorageRepository->findUnexpiredBy([ + 'content_id' => $content->id, + 'token' => $request->get('token'), + 'mail' => $request->get('mail'), + ]); + + if (count($unexpiredToken) > 0) { + $canRead = true; } - if (str_replace(PasswordProvided::COOKIE_PREFIX, '', $name) !== $value) { - continue; - } - foreach ($protections as $protection) { - /** @var ProtectedAccess $protection */ - if (md5($protection->getPassword()) === $value) { - $canRead = true; + } else { + $cookies = $this->requestStack->getCurrentRequest()->cookies; + foreach ($cookies as $name => $value) { + $cookiePrefix = substr($name, 0, \strlen(PasswordProvided::COOKIE_PREFIX)); + if (PasswordProvided::COOKIE_PREFIX !== $cookiePrefix) { + continue; + } + if (str_replace(PasswordProvided::COOKIE_PREFIX, '', $name) !== $value) { + continue; + } + foreach ($protections as $protection) { + /** @var ProtectedAccess $protection */ + if (md5($protection->getPassword()) === $value) { + $canRead = true; + } } } } @@ -106,8 +134,25 @@ public function onPreContentView(PreContentViewEvent $event): void $contentView->addParameters(['canReadProtectedContent' => $canRead]); if (!$canRead) { - $form = $this->formFactory->create(RequestProtectedAccessType::class); - $contentView->addParameters(['requestProtectedContentPasswordForm' => $form->createView()]); + if ('by_mail' == $this->getContentProtectionType($protections)) { + $form = $this->formFactory->create(RequestEmailProtectedAccessType::class); + $contentView->addParameters(['requestProtectedContentEmailForm' => $form->createView()]); + } else { + $form = $this->formFactory->create(RequestProtectedAccessType::class); + $contentView->addParameters(['requestProtectedContentPasswordForm' => $form->createView()]); + } } } + + private function getContentProtectionType(array $protections): string + { + foreach ($protections as $protection) { + /** @var ProtectedAccess $protection */ + if (!is_null($protection->getPassword()) && '' != $protection->getPassword()) { + return 'by_password'; + } + } + + return 'by_mail'; + } } diff --git a/bundle/Repository/ProtectedTokenStorageRepository.php b/bundle/Repository/ProtectedTokenStorageRepository.php new file mode 100644 index 0000000..61f7b4b --- /dev/null +++ b/bundle/Repository/ProtectedTokenStorageRepository.php @@ -0,0 +1,45 @@ +_em->createQueryBuilder() + ->select('c') + ->from(ProtectedTokenStorage::class, 'c') + ->where('c.created >= :nowMinusOneHour') + ->setParameter('nowMinusOneHour', new DateTime('now - 1 hours')); + + foreach ($criteria as $key => $criterion) { + $dbQuery->andWhere("c.$key = '$criterion'"); + } + + return $dbQuery->getQuery()->getResult(); + } + + public function findExpired(): array + { + $dbQuery = $this->_em->createQueryBuilder() + ->select('c') + ->from(ProtectedTokenStorage::class, 'c') + ->where('c.created < :nowMinusOneHour') + ->setParameter('nowMinusOneHour', new DateTime('now - 1 hours')); + + return $dbQuery->getQuery()->getResult(); + } +} diff --git a/bundle/Resources/config/services.yaml b/bundle/Resources/config/services.yaml index 498b4ab..fd6e44d 100644 --- a/bundle/Resources/config/services.yaml +++ b/bundle/Resources/config/services.yaml @@ -18,6 +18,11 @@ services: resource: '../../Controller' tags: ['controller.service_arguments'] + Novactive\Bundle\eZProtectedContentBundle\Command\CleanTokenCommand: + tags: + - { name: 'novaezprotectedcontent:cleantoken', command: 'novaezprotectedcontent:cleantoken' } + - { name: ibexa.cron.job, schedule: '0 1 * * *' } + Novactive\Bundle\eZProtectedContentBundle\Core\Tab\ProtectContent: tags: - { name: ibexa.admin_ui.tab, group: location-view } @@ -43,3 +48,7 @@ services: $repositoryConfigurationProvider: "@Ibexa\\Bundle\\Core\\ApiLoader\\RepositoryConfigurationProvider" $resolver: "@doctrine.orm.default_entity_listener_resolver" $settings: { debug: "%kernel.debug%", cache_dir: "%kernel.cache_dir%" } + + Novactive\Bundle\eZProtectedContentBundle\Listener\EmailProvided: + tags: + - { name: kernel.event_listener, event: kernel.request, method: 'onKernelRequest', priority: -100} diff --git a/bundle/Resources/translations/ezprotectedcontent.en.yml b/bundle/Resources/translations/ezprotectedcontent.en.yml index 3c8db4b..2113894 100644 --- a/bundle/Resources/translations/ezprotectedcontent.en.yml +++ b/bundle/Resources/translations/ezprotectedcontent.en.yml @@ -7,7 +7,17 @@ tab.modal.buttons.add: "Add" tab.table.th.password: "Password" tab.table.th.children_protection: "Protect Children?" tab.table.th.enabled: "Enabled?" +tab.table.th.as_email: "Protection by mail" +tab.table.th.message_email: "Message to display in send email" +tab.table.th.email: "Your email" +tab.table.th.as_email_message: "Email message defined" tab.table.th.remove: "Remove" +mail.help_message: Add {{ url }} in your message it will be replace by "Click here" and a link +mail.link: Click here +mail.subject: "Your request for access to protected content" + tab.yes: "YES" tab.no: "NO" + +'This area is email protected.': 'This area is email protected.' diff --git a/bundle/Resources/translations/ezprotectedcontent.fr.yml b/bundle/Resources/translations/ezprotectedcontent.fr.yml index 2039255..7a03be6 100644 --- a/bundle/Resources/translations/ezprotectedcontent.fr.yml +++ b/bundle/Resources/translations/ezprotectedcontent.fr.yml @@ -7,7 +7,17 @@ tab.modal.buttons.add: "Ajouter" tab.table.th.password: "Mot de passe" tab.table.th.children_protection: "Protéger les contenus enfants?" tab.table.th.enabled: "Activer?" +tab.table.th.as_email: "Protection par courriel" +tab.table.th.message_email: "Message à afficher dans le courriel envoyé" +tab.table.th.email: "Votre adresse courriel" +tab.table.th.as_email_message: "Message courriel défini" tab.table.th.remove: "Supprimer" +mail.help_message: Ajoutez {{ url }} dans votre message, cela sera remplacé par "Cliquez ici" et un lien +mail.link: "Cliquez ici" +mail.subject: "Votre demande d'accès à un contenu protégé" + tab.yes: "OUI" tab.no: "NON" + +'This area is email protected.': 'Cette zone est protégé par une vérification de courriel' diff --git a/bundle/Resources/views/themes/admin/tabs/protected_content.html.twig b/bundle/Resources/views/themes/admin/tabs/protected_content.html.twig index 4b39d50..517e318 100644 --- a/bundle/Resources/views/themes/admin/tabs/protected_content.html.twig +++ b/bundle/Resources/views/themes/admin/tabs/protected_content.html.twig @@ -31,6 +31,9 @@ {{ form_row(form.password) }} {{ form_row(form.protectChildren) }} {{ form_row(form.enabled) }} + {{ form_row(form.asEmail) }} + {{ form_row(form.emailMessage) }} + {{ form.emailMessage.vars['help']|trans }}