From 64ae9b8440ec575350cbba324d7ab22a19a6326e Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 5 Jul 2021 23:26:54 -0700 Subject: [PATCH] (REF) Extract TokenSmarty::render() from MessageTemplate::renderMessageTemplate() Most Civi message-templates have been written with a hybrid notation based on mixing tokens (eg `{contact.first_name}`) and Smarty (eg `{$foo}` or `{if}{/if}`). The notion here is to acknowledge that Token-Smarty is a distinct/de-facto templating language and provide the kind of `render()` API that you would expect from any templating language (i.e. "Give me your template, give me your data, and let me return to you a string!"). ```php $rendered = CRM_Core_TokenSmarty::render($template, $tokenData, $smartyData); ``` None of this is new functionality. It's just a refactoring which extracts code from `renderMessageTemplate()`, hardens it a bit more, and adds some more testing. This is a step toward removing `renderMessageTemplate()`. The problem with `renderMessageTemplate()` is that only handles `$contactID` -- you can't pass-through other data to the token layer. Before ------ * Support `CRM_Core_BAO_MessageTemplate::renderMessageTemplate()`, which accepts `bool $disableSmarty` and `int $contactID`. * There is no way to pass other IDs (e.g. activity and contribution IDs) to the token-processor. After ----- * Support `CRM_Core_TokenSmarty::render()`. This largely the same as `renderMessageTemplate()`, except that: * You're not specifically tied to `subject` or `text` as the template names. The list of message-templates will accept/preserve whatever keys you input (e.g. give it `html`/`text` for current compatibility; or give `msg_html`/`msg_text` to match actual DB fields) * `bool $disableSmarty` and `int $contactID` are combined into one `array $tokenContext`, which can pass through more fields * If there's an exception during process, `Smarty->pop()` will still do cleanup (`try/finally`). * `Smarty->push()` and `Smarty->pop()` only run if needed * Add test coverage from some of the peculiar ways these notations are mixed. --- CRM/Core/BAO/MessageTemplate.php | 25 ++--- CRM/Core/TokenSmarty.php | 79 +++++++++++++ tests/phpunit/CRM/Core/TokenSmartyTest.php | 123 +++++++++++++++++++++ 3 files changed, 211 insertions(+), 16 deletions(-) create mode 100644 CRM/Core/TokenSmarty.php create mode 100644 tests/phpunit/CRM/Core/TokenSmartyTest.php diff --git a/CRM/Core/BAO/MessageTemplate.php b/CRM/Core/BAO/MessageTemplate.php index ce29dd8aed..2bd753b70d 100644 --- a/CRM/Core/BAO/MessageTemplate.php +++ b/CRM/Core/BAO/MessageTemplate.php @@ -9,8 +9,6 @@ +--------------------------------------------------------------------+ */ -use Civi\Token\TokenProcessor; - /** * * @package CRM @@ -583,21 +581,16 @@ class CRM_Core_BAO_MessageTemplate extends CRM_Core_DAO_MessageTemplate { * @return array */ public static function renderMessageTemplate(array $mailContent, bool $disableSmarty, $contactID, array $smartyAssigns): array { - CRM_Core_Smarty::singleton()->pushScope($smartyAssigns); - $tokenProcessor = new TokenProcessor(\Civi::dispatcher(), ['smarty' => !$disableSmarty]); - $tokenProcessor->addMessage('html', $mailContent['html'], 'text/html'); - $tokenProcessor->addMessage('text', $mailContent['text'], 'text/plain'); - $tokenProcessor->addMessage('subject', $mailContent['subject'], 'text/plain'); - $tokenProcessor->addRow($contactID ? ['contactId' => $contactID] : []); - $tokenProcessor->evaluate(); - foreach ($tokenProcessor->getRows() as $row) { - $mailContent['html'] = $row->render('html'); - $mailContent['text'] = $row->render('text'); - $mailContent['subject'] = $row->render('subject'); + $tokenContext = ['smarty' => !$disableSmarty]; + if ($contactID) { + $tokenContext['contactId'] = $contactID; } - CRM_Core_Smarty::singleton()->popScope(); - $mailContent['subject'] = trim(preg_replace('/[\r\n]+/', ' ', $mailContent['subject'])); - return $mailContent; + $result = CRM_Core_TokenSmarty::render(CRM_Utils_Array::subset($mailContent, ['text', 'html', 'subject']), $tokenContext, $smartyAssigns); + if (isset($mailContent['subject'])) { + $result['subject'] = trim(preg_replace('/[\r\n]+/', ' ', $result['subject'])); + } + $nullSet = ['subject' => NULL, 'text' => NULL, 'html' => NULL]; + return array_merge($nullSet, $mailContent, $result); } } diff --git a/CRM/Core/TokenSmarty.php b/CRM/Core/TokenSmarty.php new file mode 100644 index 0000000000..209d7160e2 --- /dev/null +++ b/CRM/Core/TokenSmarty.php @@ -0,0 +1,79 @@ + 10}...{/if}`. + * + * NOTE: It is arguable about whether the existence of this format is a good thing, + * but it does exist, and this helper makes it a little easier to work with. + */ +class CRM_Core_TokenSmarty { + + /** + * Render some template(s), evaluating token expressions and Smarty expressions. + * + * This helper simplifies usage of hybrid notation. As a simplification, it may not be optimal for processing + * large batches (e.g. CiviMail or scheduled-reminders), but it's a little more convenient for 1-by-1 use-cases. + * + * @param array $messages + * Message templates. Any mix of the following templates ('text', 'html', 'subject', 'msg_text', 'msg_html', 'msg_subject'). + * Ex: ['subject' => 'Hello {contact.display_name}', 'text' => 'What up?']. + * Note: The content-type may be inferred by default. A key like 'html' or 'msg_html' indicates HTML formatting; any other key indicates text formatting. + * @param array $tokenContext + * Ex: ['contactId' => 123, 'activityId' => 456] + * @param array|null $smartyAssigns + * List of data to export via Smarty. + * Data is only exported temporarily (long enough to execute this render() method). + * @return array + * Rendered messages. These match the various inputted $messages. + * Ex: ['msg_subject' => 'Hello Bob Roberts', 'msg_text' => 'What up?'] + * @internal + */ + public static function render(array $messages, array $tokenContext = [], array $smartyAssigns = []): array { + $tokenContextDefaults = [ + 'controller' => __CLASS__, + 'smarty' => TRUE, + ]; + $tokenProcessor = new TokenProcessor(\Civi::dispatcher(), array_merge($tokenContextDefaults, $tokenContext)); + $tokenProcessor->addRow([]); + $useSmarty = !empty($tokenProcessor->context['smarty']); + + // Load templates + foreach ($messages as $messageId => $messageTpl) { + $format = preg_match('/html/', $messageId) ? 'text/html' : 'text/plain'; + $tokenProcessor->addMessage($messageId, $messageTpl, $format); + } + + // Evaluate/render templates + try { + if ($useSmarty) { + CRM_Core_Smarty::singleton()->pushScope($smartyAssigns); + } + $tokenProcessor->evaluate(); + foreach ($messages as $messageId => $ign) { + foreach ($tokenProcessor->getRows() as $row) { + $result[$messageId] = $row->render($messageId); + } + } + } + finally { + if ($useSmarty) { + CRM_Core_Smarty::singleton()->popScope(); + } + } + + return $result; + } + +} diff --git a/tests/phpunit/CRM/Core/TokenSmartyTest.php b/tests/phpunit/CRM/Core/TokenSmartyTest.php new file mode 100644 index 0000000000..8704c42b53 --- /dev/null +++ b/tests/phpunit/CRM/Core/TokenSmartyTest.php @@ -0,0 +1,123 @@ +useTransaction(); + $this->contactId = $this->individualCreate([ + 'first_name' => 'Bob', + 'last_name' => 'Roberts', + ]); + } + + /** + * A template which uses both token-data and Smarty-data. + */ + public function testMixedData() { + $rendered = CRM_Core_TokenSmarty::render( + ['msg_subject' => 'First name is {contact.first_name}. ExtraFoo is {$extra.foo}.'], + ['contactId' => $this->contactId], + ['extra' => ['foo' => 'foobar']] + ); + $this->assertEquals('First name is Bob. ExtraFoo is foobar.', $rendered['msg_subject']); + } + + /** + * A template which uses token-data as part of a Smarty expression. + */ + public function testTokenInSmarty() { + $rendered = CRM_Core_TokenSmarty::render( + ['msg_html' => '

{assign var="greeting" value="{contact.email_greeting}"}Greeting: {$greeting}!

'], + ['contactId' => $this->contactId], + [] + ); + $this->assertEquals('

Greeting: Dear Bob!

', $rendered['msg_html']); + + $rendered = CRM_Core_TokenSmarty::render( + ['msg_html' => '

{if !empty("{contact.contact_id}")}Yes CID{else}No CID{/if}

'], + ['contactId' => $this->contactId], + [] + ); + $this->assertEquals('

Yes CID

', $rendered['msg_html']); + } + + /** + * A template that specifically opts out of Smarty. + */ + public function testDisableSmarty() { + $rendered = CRM_Core_TokenSmarty::render( + ['msg_subject' => 'First name is {contact.first_name}. ExtraFoo is {$extra.foo}.'], + ['contactId' => $this->contactId, 'smarty' => FALSE], + ['extra' => ['foo' => 'foobar']] + ); + $this->assertEquals('First name is Bob. ExtraFoo is {$extra.foo}.', $rendered['msg_subject']); + } + + /** + * Someone malicious gives cutesy expressions (via token-content) that tries to provoke extra evaluation. + */ + public function testCutesyTokenData() { + $cutesyContactId = $this->individualCreate([ + 'first_name' => '{$extra.foo}{contact.last_name}', + 'last_name' => 'Roberts', + ]); + $rendered = CRM_Core_TokenSmarty::render( + ['msg_subject' => 'First name is {contact.first_name}. ExtraFoo is {$extra.foo}.'], + ['contactId' => $cutesyContactId], + ['extra' => ['foo' => 'foobar']] + ); + $this->assertEquals('First name is {$extra.foo}{contact.last_name}. ExtraFoo is foobar.', $rendered['msg_subject']); + } + + /** + * Someone malicious gives cutesy expressions (via Smarty-content) that tries to provoke extra evaluation. + */ + public function testCutesySmartyData() { + $rendered = CRM_Core_TokenSmarty::render( + ['msg_subject' => 'First name is {contact.first_name}. ExtraFoo is {$extra.foo}.'], + ['contactId' => $this->contactId], + ['extra' => ['foo' => '{contact.last_name}{$extra.foo}']] + ); + $this->assertEquals('First name is Bob. ExtraFoo is {contact.last_name}{$extra.foo}.', $rendered['msg_subject']); + } + + /** + * The same tokens are used in multiple parts of the template - without redundant evaluation. + */ + public function testDataLoadCount() { + // Define a token `{counter.i}` which increments whenever tokens are evaluated. + Civi::dispatcher()->addListener('civi.token.eval', function (\Civi\Token\Event\TokenValueEvent $e) { + static $i; + foreach ($e->getRows() as $row) { + /** @var \Civi\Token\TokenRow $row */ + $i = is_null($i) ? 1 : (1 + $i); + $row->tokens('counter', 'i', 'eval#' . $i); + } + }); + $templates = [ + 'subject' => 'Subject {counter.i}', + 'body' => 'Body {counter.i} is really {counter.i}.', + ]; + $rendered = CRM_Core_TokenSmarty::render($templates, ['contactId' => $this->contactId]); + $this->assertEquals('Subject eval#1', $rendered['subject']); + $this->assertEquals('Body eval#1 is really eval#1.', $rendered['body']); + + $rendered = CRM_Core_TokenSmarty::render($templates, ['contactId' => $this->contactId]); + $this->assertEquals('Subject eval#2', $rendered['subject']); + $this->assertEquals('Body eval#2 is really eval#2.', $rendered['body']); + + } + +} -- 2.25.1