diff --git a/src/Bundle/ChillMainBundle/Controller/WorkflowViewSendPublicController.php b/src/Bundle/ChillMainBundle/Controller/WorkflowViewSendPublicController.php index 88269d5bb..084fcdce0 100644 --- a/src/Bundle/ChillMainBundle/Controller/WorkflowViewSendPublicController.php +++ b/src/Bundle/ChillMainBundle/Controller/WorkflowViewSendPublicController.php @@ -12,14 +12,69 @@ declare(strict_types=1); namespace Chill\MainBundle\Controller; use Chill\MainBundle\Entity\Workflow\EntityWorkflowSend; +use Chill\MainBundle\Entity\Workflow\EntityWorkflowSendView; +use Chill\MainBundle\Workflow\EntityWorkflowManager; +use Chill\MainBundle\Workflow\Exception\HandlerWithPublicViewNotFoundException; +use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Clock\ClockInterface; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Routing\Annotation\Route; +use Twig\Environment; -class WorkflowViewSendPublicController +final readonly class WorkflowViewSendPublicController { - #[Route('/public/main/workflow/send/{uuid}/view/{verificationKey}', methods: ['GET'], name: 'chill_main_workflow_send_view_public')] - public function __invoke(EntityWorkflowSend $workflowSend, string $verificationKey): Response + public const LOG_PREFIX = '[workflow-view-send-public-controller] '; + + public function __construct( + private EntityManagerInterface $entityManager, + private LoggerInterface $chillLogger, + private EntityWorkflowManager $entityWorkflowManager, + private ClockInterface $clock, + private Environment $environment, + ) {} + + #[Route('/public/main/workflow/send/{uuid}/view/{verificationKey}', name: 'chill_main_workflow_send_view_public', methods: ['GET'])] + public function __invoke(EntityWorkflowSend $workflowSend, string $verificationKey, Request $request): Response { - return new Response('ok'); + if (50 < $workflowSend->getNumberOfErrorTrials()) { + throw new AccessDeniedHttpException('number of trials exceeded, no more access allowed'); + } + + if ($verificationKey !== $workflowSend->getPrivateToken()) { + $this->chillLogger->info(self::LOG_PREFIX.'Invalid trial for this send', ['client_ip' => $request->getClientIp()]); + $workflowSend->increaseErrorTrials(); + $this->entityManager->flush(); + + throw new AccessDeniedHttpException('invalid verification key'); + } + + if ($this->clock->now() > $workflowSend->getExpireAt()) { + return new Response( + $this->environment->render('@ChillMain/Workflow/workflow_view_send_public_expired.html.twig'), + 409 + ); + } + + if (100 < $workflowSend->getViews()->count()) { + $this->chillLogger->info(self::LOG_PREFIX.'100 view reached, not allowed to see it again'); + throw new AccessDeniedHttpException('100 views reached, not allowed to see it again'); + } + + try { + $response = new Response( + $this->entityWorkflowManager->renderPublicView($workflowSend), + ); + + $view = new EntityWorkflowSendView($workflowSend, $this->clock->now(), $request->getClientIp()); + $this->entityManager->persist($view); + $this->entityManager->flush(); + + return $response; + } catch (HandlerWithPublicViewNotFoundException $e) { + throw new \RuntimeException('Could not render the public view', previous: $e); + } } } diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowSend.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowSend.php index dd6aa9044..186cf64e6 100644 --- a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowSend.php +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowSend.php @@ -119,6 +119,11 @@ class EntityWorkflowSend implements TrackCreationInterface return $this->privateToken; } + public function getEntityWorkflowStep(): EntityWorkflowStep + { + return $this->entityWorkflowStep; + } + public function getUuid(): UuidInterface { return $this->uuid; diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_view_send_public_expired.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_view_send_public_expired.html.twig new file mode 100644 index 000000000..d026c37b1 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_view_send_public_expired.html.twig @@ -0,0 +1,12 @@ + + +
+ +{{ 'workflow.public_link.expired_link_explanation'|trans }}
+ + diff --git a/src/Bundle/ChillMainBundle/Tests/Controller/WorkflowViewSendPublicControllerTest.php b/src/Bundle/ChillMainBundle/Tests/Controller/WorkflowViewSendPublicControllerTest.php new file mode 100644 index 000000000..d9728b986 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Controller/WorkflowViewSendPublicControllerTest.php @@ -0,0 +1,234 @@ +prophesize(Environment::class); + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldNotBeCalled(); + + $controller = new WorkflowViewSendPublicController($entityManager->reveal(), new NullLogger(), new EntityWorkflowManager([], new Registry()), new MockClock(), $environment->reveal()); + + self::expectException(AccessDeniedHttpException::class); + + $send = $this->buildEntityWorkflowSend(); + + for ($i = 0; $i < 51; ++$i) { + $send->increaseErrorTrials(); + } + + $controller($send, $send->getPrivateToken(), new Request()); + } + + public function testInvalidVerificationKey(): void + { + $environment = $this->prophesize(Environment::class); + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldBeCalled(); + + $controller = new WorkflowViewSendPublicController($entityManager->reveal(), new NullLogger(), new EntityWorkflowManager([], new Registry()), new MockClock(), $environment->reveal()); + + self::expectException(AccessDeniedHttpException::class); + + $send = $this->buildEntityWorkflowSend(); + + try { + $controller($send, 'some-token', new Request()); + } catch (AccessDeniedHttpException $e) { + self::assertEquals(1, $send->getNumberOfErrorTrials()); + + throw $e; + } + } + + public function testExpiredLink(): void + { + $environment = $this->prophesize(Environment::class); + $environment->render('@ChillMain/Workflow/workflow_view_send_public_expired.html.twig')->willReturn('test'); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldNotBeCalled(); + + $controller = new WorkflowViewSendPublicController($entityManager->reveal(), new NullLogger(), new EntityWorkflowManager([], new Registry()), new MockClock('next year'), $environment->reveal()); + + $send = $this->buildEntityWorkflowSend(); + + $response = $controller($send, $send->getPrivateToken(), new Request()); + + self::assertEquals('test', $response->getContent()); + self::assertEquals(409, $response->getStatusCode()); + } + + public function testNoHandlerFound(): void + { + $environment = $this->prophesize(Environment::class); + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldNotBeCalled(); + + $controller = new WorkflowViewSendPublicController( + $entityManager->reveal(), + new NullLogger(), + new EntityWorkflowManager([], new Registry()), + new MockClock(), + $environment->reveal(), + ); + + self::expectException(\RuntimeException::class); + + $send = $this->buildEntityWorkflowSend(); + $controller($send, $send->getPrivateToken(), new Request()); + } + + public function testHappyScenario(): void + { + $send = $this->buildEntityWorkflowSend(); + $environment = $this->prophesize(Environment::class); + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::that(function (EntityWorkflowSendView $view) use ($send) { + return $send === $view->getSend(); + }))->shouldBeCalled(); + $entityManager->flush()->shouldBeCalled(); + + $controller = new WorkflowViewSendPublicController( + $entityManager->reveal(), + new NullLogger(), + new EntityWorkflowManager([ + $this->buildFakeHandler(), + ], new Registry()), + new MockClock(), + $environment->reveal(), + ); + + $response = $controller($send, $send->getPrivateToken(), $this->buildRequest()); + + self::assertEquals(200, $response->getStatusCode()); + self::assertEquals('content', $response->getContent()); + } + + private function buildFakeHandler(): EntityWorkflowHandlerInterface&EntityWorkflowWithPublicViewInterface + { + return new class () implements EntityWorkflowWithPublicViewInterface, EntityWorkflowHandlerInterface { + public function getDeletionRoles(): array + { + throw new \BadMethodCallException('not implemented'); + } + + public function getEntityData(EntityWorkflow $entityWorkflow, array $options = []): array + { + throw new \BadMethodCallException('not implemented'); + } + + public function getEntityTitle(EntityWorkflow $entityWorkflow, array $options = []): string + { + throw new \BadMethodCallException('not implemented'); + } + + public function getRelatedEntity(EntityWorkflow $entityWorkflow): ?object + { + throw new \BadMethodCallException('not implemented'); + } + + public function getRelatedObjects(object $object): array + { + throw new \BadMethodCallException('not implemented'); + } + + public function getRoleShow(EntityWorkflow $entityWorkflow): ?string + { + throw new \BadMethodCallException('not implemented'); + } + + public function getSuggestedUsers(EntityWorkflow $entityWorkflow): array + { + throw new \BadMethodCallException('not implemented'); + } + + public function getTemplate(EntityWorkflow $entityWorkflow, array $options = []): string + { + throw new \BadMethodCallException('not implemented'); + } + + public function getTemplateData(EntityWorkflow $entityWorkflow, array $options = []): array + { + throw new \BadMethodCallException('not implemented'); + } + + public function isObjectSupported(object $object): bool + { + throw new \BadMethodCallException('not implemented'); + } + + public function supports(EntityWorkflow $entityWorkflow, array $options = []): bool + { + return true; + } + + public function supportsFreeze(EntityWorkflow $entityWorkflow, array $options = []): bool + { + return false; + } + + public function findByRelatedEntity(object $object): array + { + throw new \BadMethodCallException('not implemented'); + } + + public function renderPublicView(EntityWorkflowSend $entityWorkflowSend): string + { + return 'content'; + } + }; + } + + private function buildRequest(): Request + { + return Request::create('/test', server: ['REMOTE_ADDR' => '10.0.0.10']); + } + + private function buildEntityWorkflowSend(): EntityWorkflowSend + { + $entityWorkflow = new EntityWorkflow(); + + $step = $entityWorkflow->getCurrentStep(); + + return new EntityWorkflowSend($step, new ThirdParty(), new \DateTimeImmutable('next month')); + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Workflow/Messenger/PostSendExternalMessageHandlerTest.php b/src/Bundle/ChillMainBundle/Tests/Workflow/Messenger/PostSendExternalMessageHandlerTest.php index a6f3172a7..9f3542291 100644 --- a/src/Bundle/ChillMainBundle/Tests/Workflow/Messenger/PostSendExternalMessageHandlerTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Workflow/Messenger/PostSendExternalMessageHandlerTest.php @@ -62,7 +62,7 @@ class PostSendExternalMessageHandlerTest extends TestCase private function buildCheckAddressCallback(string $emailToCheck): callable { - return fn(TemplatedEmail $email): bool => in_array($emailToCheck, array_map(fn (Address $addr) => $addr->getAddress(), $email->getTo()), true); + return fn (TemplatedEmail $email): bool => in_array($emailToCheck, array_map(fn (Address $addr) => $addr->getAddress(), $email->getTo()), true); } private function buildEntityWorkflow(): EntityWorkflow diff --git a/src/Bundle/ChillMainBundle/Workflow/EntityWorkflowManager.php b/src/Bundle/ChillMainBundle/Workflow/EntityWorkflowManager.php index 9f7b54ccd..f054826c4 100644 --- a/src/Bundle/ChillMainBundle/Workflow/EntityWorkflowManager.php +++ b/src/Bundle/ChillMainBundle/Workflow/EntityWorkflowManager.php @@ -13,9 +13,16 @@ namespace Chill\MainBundle\Workflow; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; +use Chill\MainBundle\Entity\Workflow\EntityWorkflowSend; use Chill\MainBundle\Workflow\Exception\HandlerNotFoundException; +use Chill\MainBundle\Workflow\Exception\HandlerWithPublicViewNotFoundException; use Symfony\Component\Workflow\Registry; +/** + * Manage the handler and performs some operation on handlers. + * + * Each handler must implement @{EntityWorkflowHandlerInterface::class}. + */ class EntityWorkflowManager { /** @@ -63,4 +70,26 @@ class EntityWorkflowManager return []; } + + /** + * Renders the public view for the given entity workflow send. + * + * @param EntityWorkflowSend $entityWorkflowSend the entity workflow send object + * + * @return string the rendered public view + * + * @throws HandlerWithPublicViewNotFoundException if no handler with public view is found + */ + public function renderPublicView(EntityWorkflowSend $entityWorkflowSend): string + { + $entityWorkflow = $entityWorkflowSend->getEntityWorkflowStep()->getEntityWorkflow(); + + foreach ($this->handlers as $handler) { + if ($handler instanceof EntityWorkflowWithPublicViewInterface && $handler->supports($entityWorkflow)) { + return $handler->renderPublicView($entityWorkflowSend); + } + } + + throw new HandlerWithPublicViewNotFoundException(); + } } diff --git a/src/Bundle/ChillMainBundle/Workflow/EntityWorkflowWithPublicViewInterface.php b/src/Bundle/ChillMainBundle/Workflow/EntityWorkflowWithPublicViewInterface.php new file mode 100644 index 000000000..2d4eefb9e --- /dev/null +++ b/src/Bundle/ChillMainBundle/Workflow/EntityWorkflowWithPublicViewInterface.php @@ -0,0 +1,24 @@ +