Skip to content

Commit

Permalink
Implemented downloadable file tokens
Browse files Browse the repository at this point in the history
Implemented helper methods for gateways to automatically convert file tokens into download URIs.
  • Loading branch information
Toflar committed Nov 18, 2024
1 parent ffffe5f commit 853d3f0
Show file tree
Hide file tree
Showing 9 changed files with 194 additions and 26 deletions.
11 changes: 11 additions & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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),
Expand All @@ -57,6 +66,8 @@
$services->set(BulkyItemStorage::class)
->args([
service('contao.filesystem.virtual.'.Terminal42NotificationCenterExtension::BULKY_ITEMS_VFS_NAME),
service('router'),
service('uri_signer'),
])
;

Expand Down
20 changes: 15 additions & 5 deletions src/BulkyItem/BulkyItemStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}
Expand Down Expand Up @@ -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);
}
}
13 changes: 12 additions & 1 deletion src/ContaoManager/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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')
;
}
}
51 changes: 51 additions & 0 deletions src/Controller/DownloadBulkyItemController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace Terminal42\NotificationCenterBundle\Controller;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpFoundation\UriSigner;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Terminal42\NotificationCenterBundle\BulkyItem\BulkyItemStorage;

#[Route('/notifications/download/{voucher}', 'nc_bulky_item_download', requirements: ['voucher' => 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
;
}

Expand Down
79 changes: 69 additions & 10 deletions src/Gateway/AbstractGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
{
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
}
30 changes: 26 additions & 4 deletions src/Gateway/MailerGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -128,15 +129,15 @@ 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);
$text = Html2Text::convert($html);
break;
case 'textAndHtml':
$html = $this->renderEmailTemplate($parcel);
$text = $this->replaceTokensAndInsertTags($parcel, $languageConfig->getString('email_text'));
$text = $this->renderEmailText($parcel, $languageConfig);
break;
}

Expand All @@ -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 */
Expand Down Expand Up @@ -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, '<br>');
}

$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;
Expand Down
10 changes: 6 additions & 4 deletions tests/BulkyItem/BulkItemStorageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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));
Expand All @@ -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'));
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}

Expand Down
Loading

0 comments on commit 853d3f0

Please sign in to comment.