diff --git a/config/services.php b/config/services.php index 9390a619..a79a59f7 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; @@ -31,6 +32,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 +66,8 @@ $services->set(BulkyItemStorage::class) ->args([ service('contao.filesystem.virtual.'.Terminal42NotificationCenterExtension::BULKY_ITEMS_VFS_NAME), + service('router'), + service('uri_signer'), ]) ; diff --git a/src/BulkyItem/BulkyItemStorage.php b/src/BulkyItem/BulkyItemStorage.php index f9401b5e..466b61a3 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,15 @@ public function prune(): void } } - public static function validateVoucherFormat(string $voucher): bool + public function generatePublicUri(string $voucher): 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), + ); + } - 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..c6e62531 --- /dev/null +++ b/src/Controller/DownloadBulkyItemController.php @@ -0,0 +1,51 @@ + BulkyItemStorage::VOUCHER_REGEX])] +class DownloadBulkyItemController +{ + public function __construct( + private UriSigner $uriSigner, + private 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', $bulkyItem->getMimeType()); + $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/Gateway/AbstractGateway.php b/src/Gateway/AbstractGateway.php index 0f979f06..329043e6 100644 --- a/src/Gateway/AbstractGateway.php +++ b/src/Gateway/AbstractGateway.php @@ -6,6 +6,7 @@ use Contao\CoreBundle\InsertTag\InsertTagParser; use Contao\CoreBundle\String\SimpleTokenParser; +use Contao\StringUtil; use Psr\Container\ContainerInterface; use Terminal42\NotificationCenterBundle\BulkyItem\BulkyItemStorage; use Terminal42\NotificationCenterBundle\Exception\Parcel\CouldNotDeliverParcelException; @@ -16,6 +17,8 @@ use Terminal42\NotificationCenterBundle\Parcel\Stamp\StampInterface; use Terminal42\NotificationCenterBundle\Parcel\Stamp\TokenCollectionStamp; use Terminal42\NotificationCenterBundle\Receipt\Receipt; +use Terminal42\NotificationCenterBundle\Token\Token; +use Terminal42\NotificationCenterBundle\Token\TokenCollection; abstract class AbstractGateway implements GatewayInterface { @@ -80,20 +83,44 @@ 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 = $tokenCollection ?? $parcel->getStamp(TokenCollectionStamp::class)?->tokenCollection; + + if (!$tokenCollection instanceof TokenCollection) { + return $value; } - return $value; + return $simpleTokenParser->parse( + $value, + $tokenCollection->forSimpleTokenParser(), + ); + } + + protected function createTokenCollectionWithPublicBulkyItemUris(TokenCollection $tokenCollection, string $separatorIfMultiple = "\n"): TokenCollection + { + $bulkyItemStorage = $this->getBulkyItemStorage(); + + if (null === $bulkyItemStorage) { + return $tokenCollection; + } + + $newTokenCollection = new TokenCollection(); + + foreach ($tokenCollection as $token) { + $newToken = $this->createPublicUriToken($token, $bulkyItemStorage, $separatorIfMultiple); + $newTokenCollection->add($newToken ?? $token); + } + + return $newTokenCollection; } protected function replaceInsertTags(string $value): string @@ -105,9 +132,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 @@ -165,4 +196,32 @@ protected function getBulkyItemStorage(): BulkyItemStorage|null return !$bulkyItemStorage instanceof BulkyItemStorage ? null : $bulkyItemStorage; } + + private function createPublicUriToken(Token $token, BulkyItemStorage $bulkyItemStorage, string $separatorIfMultiple = "\n"): Token|null + { + $possibleVouchers = StringUtil::trimsplit(',', $token->getParserValue()); + + $uris = []; + + foreach ($possibleVouchers as $possibleVoucher) { + // Shortcut: Not a possibly bulky item voucher anyway - continue + if (!BulkyItemStorage::validateVoucherFormat($possibleVoucher)) { + return null; + } + + if (!$publicUri = $bulkyItemStorage->generatePublicUri($possibleVoucher)) { + continue; + } + + $uris[] = $publicUri; + } + + if (empty($uris)) { + return null; + } + + $tokenValue = implode($separatorIfMultiple, $uris); + + return new Token($token->getName(), $tokenValue, $tokenValue); + } } diff --git a/src/Gateway/MailerGateway.php b/src/Gateway/MailerGateway.php index 0d971a3c..959db281 100644 --- a/src/Gateway/MailerGateway.php +++ b/src/Gateway/MailerGateway.php @@ -26,6 +26,7 @@ use Terminal42\NotificationCenterBundle\Parcel\Stamp\Mailer\EmailStamp; use Terminal42\NotificationCenterBundle\Parcel\Stamp\TokenCollectionStamp; use Terminal42\NotificationCenterBundle\Receipt\Receipt; +use Terminal42\NotificationCenterBundle\Token\TokenCollection; class MailerGateway extends AbstractGateway { @@ -128,7 +129,7 @@ private function createEmailStamp(Parcel $parcel): EmailStamp switch ($languageConfig->getString('email_mode')) { case 'textOnly': - $text = $this->replaceTokensAndInsertTags($parcel, $languageConfig->getString('email_text')); + $text = $this->renderEmailText($parcel, $languageConfig); break; case 'htmlAndAutoText': $html = $this->renderEmailTemplate($parcel); @@ -136,7 +137,7 @@ private function createEmailStamp(Parcel $parcel): EmailStamp break; case 'textAndHtml': $html = $this->renderEmailTemplate($parcel); - $text = $this->replaceTokensAndInsertTags($parcel, $languageConfig->getString('email_text')); + $text = $this->renderEmailText($parcel, $languageConfig); break; } @@ -152,6 +153,22 @@ private function createEmailStamp(Parcel $parcel): EmailStamp return $this->addAttachmentsFromTokens($languageConfig, $parcel, $stamp); } + private function renderEmailText(Parcel $parcel, LanguageConfig $languageConfig): string + { + $tokenCollection = $parcel->getStamp(TokenCollectionStamp::class)?->tokenCollection; + + // No token collection available, just replace insert tags + if (!$tokenCollection instanceof TokenCollection) { + return $this->replaceInsertTags($languageConfig->getString('email_text')); + } + + // Otherwise convert voucher tokens to public URIs + $tokenCollection = $this->createTokenCollectionWithPublicBulkyItemUris($tokenCollection); + + // Then replace tokens and insert tags with this collection explicitly + return $this->replaceTokensAndInsertTags($parcel, $languageConfig->getString('email_text'), $tokenCollection); + } + private function createEmail(Parcel $parcel): Email { /** @var EmailStamp $emailStamp */ @@ -204,13 +221,18 @@ private function renderEmailTemplate(Parcel $parcel): string $languageConfig = $parcel->getStamp(LanguageConfigStamp::class)->languageConfig; $tokenCollection = $parcel->getStamp(TokenCollectionStamp::class)?->tokenCollection; + // Convert voucher tokens to public URIs if available + if ($tokenCollection instanceof TokenCollection) { + $tokenCollection = $this->createTokenCollectionWithPublicBulkyItemUris($tokenCollection, '
'); + } + $this->contaoFramework->initialize(); $template = $this->contaoFramework->createInstance(FrontendTemplate::class, [$parcel->getMessageConfig()->getString('email_template')]); $template->charset = 'utf-8'; - $template->title = $this->replaceTokensAndInsertTags($parcel, $languageConfig->getString('email_subject')); + $template->title = $this->replaceTokensAndInsertTags($parcel, $languageConfig->getString('email_subject'), $tokenCollection); $template->css = ''; - $template->body = $this->replaceTokensAndInsertTags($parcel, StringUtil::restoreBasicEntities($languageConfig->getString('email_html'))); + $template->body = $this->replaceTokensAndInsertTags($parcel, StringUtil::restoreBasicEntities($languageConfig->getString('email_html')), $tokenCollection); $template->language = LocaleUtil::formatAsLanguageTag($languageConfig->getString('language')); $template->parsedTokens = null === $tokenCollection ? [] : $tokenCollection->forSimpleTokenParser(); $template->rawTokens = $tokenCollection; 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/Gateway/MailerGatewayTest.php b/tests/Gateway/MailerGatewayTest.php index 94d96de2..8be5d9ef 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);