diff --git a/composer.json b/composer.json index 93d1acf3..a5cd5923 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,7 @@ "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0", "symfony/mailer": "^5.4 || ^6.0 || ^7.0", "symfony/mime": "^5.4 || ^6.0 || ^7.0", + "symfony/routing": "^5.4 || ^6.0 || ^7.0", "symfony/security-core": "^5.4 || ^6.0 || ^7.0", "symfony/service-contracts": "^1.1 || ^2.0 || ^3.0", "symfony/translation-contracts": "^2.0 || ^3.0", diff --git a/config/listeners.php b/config/listeners.php index d09a0fdb..ec611d56 100644 --- a/config/listeners.php +++ b/config/listeners.php @@ -8,6 +8,7 @@ use Codefog\HasteBundle\Formatter; use Symfony\Contracts\Translation\TranslatorInterface; use Terminal42\NotificationCenterBundle\Backend\AutoSuggester; +use Terminal42\NotificationCenterBundle\BulkyItem\BulkyItemStorage; use Terminal42\NotificationCenterBundle\Config\ConfigLoader; use Terminal42\NotificationCenterBundle\EventListener\AdminEmailTokenListener; use Terminal42\NotificationCenterBundle\EventListener\Backend\BackendMenuListener; @@ -17,6 +18,7 @@ use Terminal42\NotificationCenterBundle\EventListener\Backend\DataContainer\MessageListener; use Terminal42\NotificationCenterBundle\EventListener\Backend\DataContainer\ModuleListener; use Terminal42\NotificationCenterBundle\EventListener\Backend\DataContainer\NotificationListener; +use Terminal42\NotificationCenterBundle\EventListener\BulkyItemsTokenListener; use Terminal42\NotificationCenterBundle\EventListener\DbafsMetadataListener; use Terminal42\NotificationCenterBundle\EventListener\DisableDeliveryListener; use Terminal42\NotificationCenterBundle\EventListener\DoctrineSchemaListener; @@ -100,6 +102,14 @@ ]) ; + $services->set(BulkyItemsTokenListener::class) + ->args([ + service(BulkyItemStorage::class), + service(TokenDefinitionFactoryInterface::class), + service('twig'), + ]) + ; + $services->set(DisableDeliveryListener::class); $services->set(NotificationTypeForModuleListener::class); diff --git a/config/services.php b/config/services.php index 9390a619..af0e39f6 100644 --- a/config/services.php +++ b/config/services.php @@ -10,6 +10,7 @@ use Terminal42\NotificationCenterBundle\BulkyItem\BulkyItemStorage; use Terminal42\NotificationCenterBundle\BulkyItem\FileItemFactory; use Terminal42\NotificationCenterBundle\Config\ConfigLoader; +use Terminal42\NotificationCenterBundle\Controller\DownloadBulkyItemController; use Terminal42\NotificationCenterBundle\Cron\PruneBulkyItemStorageCron; use Terminal42\NotificationCenterBundle\DependencyInjection\Terminal42NotificationCenterExtension; use Terminal42\NotificationCenterBundle\Gateway\GatewayRegistry; @@ -18,6 +19,8 @@ use Terminal42\NotificationCenterBundle\Token\Definition\Factory\ChainTokenDefinitionFactory; use Terminal42\NotificationCenterBundle\Token\Definition\Factory\CoreTokenDefinitionFactory; use Terminal42\NotificationCenterBundle\Token\Definition\Factory\TokenDefinitionFactoryInterface; +use Terminal42\NotificationCenterBundle\Twig\NotificationCenterExtension; +use Terminal42\NotificationCenterBundle\Twig\NotificationCenterRuntime; return static function (ContainerConfigurator $container): void { $services = $container->services(); @@ -31,6 +34,14 @@ ]) ; + $services->set(DownloadBulkyItemController::class) + ->args([ + service('uri_signer'), + service(BulkyItemStorage::class), + ]) + ->public() + ; + $services->set(GatewayRegistry::class) ->args([ tagged_iterator(Terminal42NotificationCenterExtension::GATEWAY_TAG), @@ -57,6 +68,8 @@ $services->set(BulkyItemStorage::class) ->args([ service('contao.filesystem.virtual.'.Terminal42NotificationCenterExtension::BULKY_ITEMS_VFS_NAME), + service('router'), + service('uri_signer'), ]) ; @@ -72,6 +85,13 @@ ]) ; + $services->set(NotificationCenterExtension::class); + $services->set(NotificationCenterRuntime::class) + ->args([ + service(BulkyItemStorage::class), + ]) + ; + $services->set(NotificationCenter::class) ->args([ service('database_connection'), diff --git a/contao/templates/.twig-root b/contao/templates/.twig-root new file mode 100644 index 00000000..e69de29b diff --git a/contao/templates/notification_center/file_token.html.twig b/contao/templates/notification_center/file_token.html.twig new file mode 100644 index 00000000..ab7b3b34 --- /dev/null +++ b/contao/templates/notification_center/file_token.html.twig @@ -0,0 +1,13 @@ +{% if format is same as 'html' %} + +{% endif %} + +{% if format is same as 'text' %} + {% for voucher, file in files %} + - [{{ file.name }} ({{ file.size|format_bytes }})]({{ notification_center_file_url(voucher) }}) + {% endfor %} +{% endif %} \ No newline at end of file diff --git a/src/BulkyItem/BulkyItemStorage.php b/src/BulkyItem/BulkyItemStorage.php index f9401b5e..2e8323c3 100644 --- a/src/BulkyItem/BulkyItemStorage.php +++ b/src/BulkyItem/BulkyItemStorage.php @@ -7,12 +7,19 @@ use Contao\CoreBundle\Filesystem\ExtraMetadata; use Contao\CoreBundle\Filesystem\VirtualFilesystemException; use Contao\CoreBundle\Filesystem\VirtualFilesystemInterface; +use Symfony\Component\HttpFoundation\UriSigner; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Uid\Uuid; class BulkyItemStorage { + public const VOUCHER_REGEX = '^\d{8}/[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$'; + public function __construct( private readonly VirtualFilesystemInterface $filesystem, + private readonly RouterInterface $router, + private readonly UriSigner $uriSigner, private readonly int $retentionPeriodInDays = 7, ) { } @@ -95,12 +102,16 @@ public function prune(): void } } - public static function validateVoucherFormat(string $voucher): bool + public function generatePublicUri(string $voucher, int|null $ttl = null): string { - if (!preg_match('@^\d{8}/@', $voucher)) { - return false; - } + return $this->uriSigner->sign( + $this->router->generate('nc_bulky_item_download', ['voucher' => $voucher], UrlGeneratorInterface::ABSOLUTE_URL), + time() + $ttl, + ); + } - return Uuid::isValid(substr($voucher, 9)); + public static function validateVoucherFormat(string $voucher): bool + { + return 1 === preg_match('@'.self::VOUCHER_REGEX.'@', $voucher); } } diff --git a/src/ContaoManager/Plugin.php b/src/ContaoManager/Plugin.php index f23ea343..64257a63 100644 --- a/src/ContaoManager/Plugin.php +++ b/src/ContaoManager/Plugin.php @@ -8,9 +8,12 @@ use Contao\ManagerPlugin\Bundle\BundlePluginInterface; use Contao\ManagerPlugin\Bundle\Config\BundleConfig; use Contao\ManagerPlugin\Bundle\Parser\ParserInterface; +use Contao\ManagerPlugin\Routing\RoutingPluginInterface; +use Symfony\Component\Config\Loader\LoaderResolverInterface; +use Symfony\Component\HttpKernel\KernelInterface; use Terminal42\NotificationCenterBundle\Terminal42NotificationCenterBundle; -class Plugin implements BundlePluginInterface +class Plugin implements BundlePluginInterface, RoutingPluginInterface { public function getBundles(ParserInterface $parser): array { @@ -20,4 +23,12 @@ public function getBundles(ParserInterface $parser): array ->setLoadAfter([ContaoCoreBundle::class]), ]; } + + public function getRouteCollection(LoaderResolverInterface $resolver, KernelInterface $kernel) + { + return $resolver + ->resolve(__DIR__.'/../Controller/DownloadBulkyItemController.php', 'attribute') + ->load(__DIR__.'/../Controller/DownloadBulkyItemController.php') + ; + } } diff --git a/src/Controller/DownloadBulkyItemController.php b/src/Controller/DownloadBulkyItemController.php new file mode 100644 index 00000000..d038ef09 --- /dev/null +++ b/src/Controller/DownloadBulkyItemController.php @@ -0,0 +1,51 @@ + BulkyItemStorage::VOUCHER_REGEX])] +class DownloadBulkyItemController +{ + public function __construct( + private readonly UriSigner $uriSigner, + private readonly BulkyItemStorage $bulkyItemStorage, + ) { + } + + public function __invoke(Request $request, string $voucher): Response + { + if (!$this->uriSigner->checkRequest($request)) { + throw new NotFoundHttpException(); + } + + if (!$bulkyItem = $this->bulkyItemStorage->retrieve($voucher)) { + throw new NotFoundHttpException(); + } + + $stream = $bulkyItem->getContents(); + + $response = new StreamedResponse( + static function () use ($stream): void { + while (!feof($stream)) { + echo fread($stream, 8192); // Read in chunks of 8 KB + flush(); + } + fclose($stream); + }, + ); + + $response->headers->set('Content-Type', 'application/octet-stream'); + $response->headers->set('Cache-Control', 'no-cache, no-store, must-revalidate'); + + return $response; + } +} diff --git a/src/DependencyInjection/Terminal42NotificationCenterExtension.php b/src/DependencyInjection/Terminal42NotificationCenterExtension.php index 464f03ba..951b1430 100644 --- a/src/DependencyInjection/Terminal42NotificationCenterExtension.php +++ b/src/DependencyInjection/Terminal42NotificationCenterExtension.php @@ -61,7 +61,7 @@ public function load(array $configs, ContainerBuilder $container): void } $container->findDefinition(BulkyItemStorage::class) - ->setArgument(1, $config['bulky_items_storage']['retention_period']) + ->setArgument(3, $config['bulky_items_storage']['retention_period']) ; } diff --git a/src/EventListener/BulkyItemsTokenListener.php b/src/EventListener/BulkyItemsTokenListener.php new file mode 100644 index 00000000..4ff252ea --- /dev/null +++ b/src/EventListener/BulkyItemsTokenListener.php @@ -0,0 +1,105 @@ +addTokenDefinition($this->tokenDefinitionFactory->create(AnythingTokenDefinition::class, 'file_item_html_*', 'file_item_html_*')) + ->addTokenDefinition($this->tokenDefinitionFactory->create(AnythingTokenDefinition::class, 'file_item_text_*', 'file_item_text_*')) + ; + } + + #[AsEventListener] + public function onCreateParcel(CreateParcelEvent $event): void + { + if (!$event->getParcel()->hasStamp(TokenCollectionStamp::class) || !$event->getParcel()->getStamp(BulkyItemsStamp::class)) { + return; + } + + $tokenCollection = $event->getParcel()->getStamp(TokenCollectionStamp::class)->tokenCollection; + + foreach ($tokenCollection as $token) { + $items = $this->extractFileItems($token, $event->getParcel()->getStamp(BulkyItemsStamp::class)); + + if ([] === $items) { + continue; + } + + $tokenCollection->addToken($this->createFileToken($event->getParcel(), $token, $items, 'html', HtmlTokenDefinition::class)); + $tokenCollection->addToken($this->createFileToken($event->getParcel(), $token, $items, 'text', TextTokenDefinition::class)); + } + } + + /** + * @param array $items + */ + private function createFileToken(Parcel $parcel, Token $token, array $items, string $format, string $tokenDefinitionClass): Token + { + $content = $this->twig->render('@Contao/notification_center/file_token.html.twig', [ + 'files' => $items, + 'parcel' => $parcel, + 'format' => $format, + ]); + + $tokenName = 'file_item_'.$format.'_'.$token->getName(); + + return $this->tokenDefinitionFactory->create($tokenDefinitionClass, $tokenName, $tokenName) + ->createToken($tokenName, $content) + ; + } + + /** + * @return array + */ + private function extractFileItems(Token $token, BulkyItemsStamp $bulkyItemsStamp): array + { + $possibleVouchers = StringUtil::trimsplit(',', $token->getParserValue()); + $items = []; + + foreach ($possibleVouchers as $possibleVoucher) { + // Shortcut: Not a possibly bulky item voucher anyway - continue + if (!BulkyItemStorage::validateVoucherFormat($possibleVoucher)) { + continue; + } + + if (!$bulkyItemsStamp->has($possibleVoucher)) { + continue; + } + + if ($item = $this->bulkyItemStorage->retrieve($possibleVoucher)) { + $items[$possibleVoucher] = $item; + } + } + + return $items; + } +} diff --git a/src/Gateway/AbstractGateway.php b/src/Gateway/AbstractGateway.php index 0f979f06..10c58adc 100644 --- a/src/Gateway/AbstractGateway.php +++ b/src/Gateway/AbstractGateway.php @@ -16,6 +16,7 @@ use Terminal42\NotificationCenterBundle\Parcel\Stamp\StampInterface; use Terminal42\NotificationCenterBundle\Parcel\Stamp\TokenCollectionStamp; use Terminal42\NotificationCenterBundle\Receipt\Receipt; +use Terminal42\NotificationCenterBundle\Token\TokenCollection; abstract class AbstractGateway implements GatewayInterface { @@ -80,20 +81,26 @@ abstract protected function doSealParcel(Parcel $parcel): Parcel; abstract protected function doSendParcel(Parcel $parcel): Receipt; - protected function replaceTokens(Parcel $parcel, string $value): string + /** + * You can provide an optional TokenCollection if you want to force a certain token collection. Otherwise, they + * will be taken from the TokenCollectionStamp. + */ + protected function replaceTokens(Parcel $parcel, string $value, TokenCollection|null $tokenCollection = null): string { - if (!$parcel->hasStamp(TokenCollectionStamp::class)) { + if (!$simpleTokenParser = $this->getSimpleTokenParser()) { return $value; } - if ($simpleTokenParser = $this->getSimpleTokenParser()) { - return $simpleTokenParser->parse( - $value, - $parcel->getStamp(TokenCollectionStamp::class)->tokenCollection->forSimpleTokenParser(), - ); + $tokenCollection ??= $parcel->getStamp(TokenCollectionStamp::class)?->tokenCollection; + + if (!$tokenCollection instanceof TokenCollection) { + return $value; } - return $value; + return $simpleTokenParser->parse( + $value, + $tokenCollection->forSimpleTokenParser(), + ); } protected function replaceInsertTags(string $value): string @@ -105,9 +112,13 @@ protected function replaceInsertTags(string $value): string return $value; } - protected function replaceTokensAndInsertTags(Parcel $parcel, string $value): string + /** + * You can provide an optional TokenCollection if you want to force a certain token collection. Otherwise, they + * will be taken from the TokenCollectionStamp. + */ + protected function replaceTokensAndInsertTags(Parcel $parcel, string $value, TokenCollection|null $tokenCollection = null): string { - return $this->replaceInsertTags($this->replaceTokens($parcel, $value)); + return $this->replaceInsertTags($this->replaceTokens($parcel, $value, $tokenCollection)); } protected function isBulkyItemVoucher(Parcel $parcel, string $voucher): bool diff --git a/src/Twig/NotificationCenterExtension.php b/src/Twig/NotificationCenterExtension.php new file mode 100644 index 00000000..9382e89f --- /dev/null +++ b/src/Twig/NotificationCenterExtension.php @@ -0,0 +1,18 @@ +bulkyItemStorage->generatePublicUri($voucher, $ttl); + } +} diff --git a/tests/BulkyItem/BulkItemStorageTest.php b/tests/BulkyItem/BulkItemStorageTest.php index 131314df..2e1db797 100644 --- a/tests/BulkyItem/BulkItemStorageTest.php +++ b/tests/BulkyItem/BulkItemStorageTest.php @@ -9,6 +9,8 @@ use Contao\CoreBundle\Filesystem\FilesystemItemIterator; use Contao\CoreBundle\Filesystem\VirtualFilesystemInterface; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\UriSigner; +use Symfony\Component\Routing\RouterInterface; use Terminal42\NotificationCenterBundle\BulkyItem\BulkyItemStorage; use Terminal42\NotificationCenterBundle\BulkyItem\FileItem; @@ -72,7 +74,7 @@ function (ExtraMetadata $meta) { ) ; - $storage = new BulkyItemStorage($vfs); + $storage = new BulkyItemStorage($vfs, $this->createMock(RouterInterface::class), $this->createMock(UriSigner::class)); $voucher = $storage->store($this->createFileItem()); $this->assertTrue(BulkyItemStorage::validateVoucherFormat($voucher)); @@ -88,7 +90,7 @@ public function testHas(): void ->willReturn(true) ; - $storage = new BulkyItemStorage($vfs); + $storage = new BulkyItemStorage($vfs, $this->createMock(RouterInterface::class), $this->createMock(UriSigner::class)); $this->assertTrue($storage->has('a10aed4d-abe1-498f-adfc-b2e54fbbcbde')); } @@ -118,7 +120,7 @@ public function testRetrieve(): void ->willReturn($this->createStream()) ; - $storage = new BulkyItemStorage($vfs); + $storage = new BulkyItemStorage($vfs, $this->createMock(RouterInterface::class), $this->createMock(UriSigner::class)); $item = $storage->retrieve('a10aed4d-abe1-498f-adfc-b2e54fbbcbde'); $this->assertInstanceOf(FileItem::class, $item); @@ -154,7 +156,7 @@ public function testPrune(): void ->with('20220101') ; - $storage = new BulkyItemStorage($vfs); + $storage = new BulkyItemStorage($vfs, $this->createMock(RouterInterface::class), $this->createMock(UriSigner::class)); $storage->prune(); } diff --git a/tests/EventListener/BulkyItemsTokenListenerTest.php b/tests/EventListener/BulkyItemsTokenListenerTest.php new file mode 100644 index 00000000..87c54dab --- /dev/null +++ b/tests/EventListener/BulkyItemsTokenListenerTest.php @@ -0,0 +1,115 @@ +createMock(TokenDefinitionFactoryInterface::class); + $tokenDefinitionFactory + ->expects($this->exactly(2)) + ->method('create') + ->willReturnCallback(static fn (string $definitionClass, string $tokenName, string $translationKey) => new $definitionClass($tokenName, $translationKey)) + ; + + $event = new GetTokenDefinitionsForNotificationTypeEvent($this->createMock(NotificationTypeInterface::class)); + $listener = new BulkyItemsTokenListener( + $this->createMock(BulkyItemStorage::class), + $tokenDefinitionFactory, + $this->createMock(Environment::class), + ); + + $listener->onGetTokenDefinitions($event); + + $this->assertSame(['file_item_html_*', 'file_item_text_*'], array_keys($event->getTokenDefinitions())); + } + + public function testOnCreateParcelSkipsIfStampsAreMissing(): void + { + $parcel = new Parcel(MessageConfig::fromArray([])); + $event = new CreateParcelEvent($parcel); + + $listener = new BulkyItemsTokenListener( + $this->createMock(BulkyItemStorage::class), + $this->createMock(TokenDefinitionFactoryInterface::class), + $this->createMock(Environment::class), + ); + + $listener->onCreateParcel($event); + + $this->addToAssertionCount(1); // Ensure no exceptions or errors + } + + public function testOnCreateParcelProcessesTokens(): void + { + $bulkyItemStorage = $this->createMock(BulkyItemStorage::class); + $bulkyItemStorage + ->method('retrieve') + ->willReturn($this->createMock(BulkyItemInterface::class)) + ; + + $tokenDefinitionFactory = $this->createMock(TokenDefinitionFactoryInterface::class); + $tokenDefinitionFactory + ->method('create') + ->willReturnCallback(static fn (string $definitionClass, string $tokenName, string $translationKey) => new $definitionClass($tokenName, $translationKey)) + ; + + $twig = $this->createMock(Environment::class); + $twig + ->method('render') + ->willReturnCallback( + function (string $template, array $context) { + $this->assertSame('@Contao/notification_center/file_token.html.twig', $template); + + return match ($context['format']) { + 'html' => 'rendered_html_content', + 'text' => 'rendered_text_content', + default => $this->fail('Invalid format!'), + }; + }, + ) + ; + + $tokenCollection = new TokenCollection(); + $tokenCollection->addToken(new Token('form_upload', '20221228/a10aed4d-abe1-498f-adfc-b2e54fbbcbde', '20221228/a10aed4d-abe1-498f-adfc-b2e54fbbcbde')); + + $parcel = new Parcel(MessageConfig::fromArray([])); + $parcel = $parcel->withStamp(new TokenCollectionStamp($tokenCollection)); + $parcel = $parcel->withStamp(new BulkyItemsStamp(['20221228/a10aed4d-abe1-498f-adfc-b2e54fbbcbde'])); + $event = new CreateParcelEvent($parcel); + + $listener = new BulkyItemsTokenListener( + $bulkyItemStorage, + $tokenDefinitionFactory, + $twig, + ); + + $listener->onCreateParcel($event); + + $tokenCollection = $event->getParcel()->getStamp(TokenCollectionStamp::class)?->tokenCollection; + $this->assertInstanceOf(TokenCollection::class, $tokenCollection); + $this->assertTrue($tokenCollection->has('file_item_html_form_upload')); + $this->assertTrue($tokenCollection->has('file_item_text_form_upload')); + $this->assertSame('rendered_html_content', $tokenCollection->getByName('file_item_html_form_upload')->getParserValue()); + $this->assertSame('rendered_text_content', $tokenCollection->getByName('file_item_text_form_upload')->getParserValue()); + } +} diff --git a/tests/Gateway/MailerGatewayTest.php b/tests/Gateway/MailerGatewayTest.php index d450fd1f..991572e1 100644 --- a/tests/Gateway/MailerGatewayTest.php +++ b/tests/Gateway/MailerGatewayTest.php @@ -15,8 +15,10 @@ use League\Flysystem\InMemory\InMemoryFilesystemAdapter; use Symfony\Component\DependencyInjection\Container; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\HttpFoundation\UriSigner; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; +use Symfony\Component\Routing\RouterInterface; use Terminal42\NotificationCenterBundle\BulkyItem\BulkyItemStorage; use Terminal42\NotificationCenterBundle\Config\LanguageConfig; use Terminal42\NotificationCenterBundle\Config\MessageConfig; @@ -88,7 +90,7 @@ static function (Email $email) use ($parsedTemplateHtml, $expectedAttachmentsCon $mailer, ); $container = new Container(); - $container->set(AbstractGateway::SERVICE_NAME_BULKY_ITEM_STORAGE, new BulkyItemStorage($vfsCollection->get('bulky_item'))); + $container->set(AbstractGateway::SERVICE_NAME_BULKY_ITEM_STORAGE, new BulkyItemStorage($vfsCollection->get('bulky_item'), $this->createMock(RouterInterface::class), $this->createMock(UriSigner::class))); $container->set(AbstractGateway::SERVICE_NAME_SIMPLE_TOKEN_PARSER, new SimpleTokenParser(new ExpressionLanguage())); $gateway->setContainer($container);