From f85e1a4bd637f7419f2427fe8a712ca0f9b4e461 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 21 Sep 2021 22:09:49 -0700 Subject: [PATCH] TokenProcessor - Filter args should require quotes (to match Smarty notation) --- Civi/Token/TokenProcessor.php | 24 +++++++++++--- tests/phpunit/CRM/Core/TokenSmartyTest.php | 20 ++++++++++++ .../CRM/Utils/TokenConsistencyTest.php | 18 +++++++++-- .../phpunit/Civi/Token/TokenProcessorTest.php | 31 +++++++++++++++++++ 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/Civi/Token/TokenProcessor.php b/Civi/Token/TokenProcessor.php index 1a7d5d5d7c..bb066183a3 100644 --- a/Civi/Token/TokenProcessor.php +++ b/Civi/Token/TokenProcessor.php @@ -387,13 +387,29 @@ class TokenProcessor { } private function visitTokens(string $expression, callable $callback): string { - // Regex examples: '{foo.bar}', '{foo.bar|whiz}', '{foo.bar|whiz:bang}' + // Regex examples: '{foo.bar}', '{foo.bar|whiz}', '{foo.bar|whiz:"bang"}', '{foo.bar|whiz:"bang":"bang"}' // Regex counter-examples: '{foobar}', '{foo bar}', '{$foo.bar}', '{$foo.bar|whiz}', '{foo.bar|whiz{bang}}' // Key observations: Civi tokens MUST have a `.` and MUST NOT have a `$`. Civi filters MUST NOT have `{}`s or `$`s. - $tokRegex = '([\w]+)\.([\w:\.]+)'; - $filterRegex = '(\w+:?\w*)'; + $tokRegex = '([\w]+)\.([\w:\.]+)'; /* EX: 'foo.bar' in '{foo.bar|whiz:"bang":"bang"}' */ + $argRegex = ':[\w": %\-_()\[\]\+/#@!,\.\?]*'; /* EX: ':"bang":"bang"' in '{foo.bar|whiz:"bang":"bang"}' */ + // Debatable: Maybe relax to this: $argRegex = ':[^{}\n]*'; /* EX: ':"bang":"bang"' in '{foo.bar|whiz:"bang":"bang"}' */ + $filterRegex = "(\w+(?:$argRegex)?)"; /* EX: 'whiz:"bang"' in '{foo.bar|whiz:"bang"' */ return preg_replace_callback(";\{$tokRegex(?:\|$filterRegex)?\};", function($m) use ($callback) { - $filterParts = isset($m[3]) ? explode(':', $m[3]) : NULL; + $filterParts = NULL; + if (isset($m[3])) { + $filterParts = []; + $enqueue = function($m) use (&$filterParts) { + $filterParts[] = $m[1]; + return ''; + }; + $unmatched = preg_replace_callback_array([ + '/^(\w+)/' => $enqueue, + '/:"([^"]+)"/' => $enqueue, + ], $m[3]); + if ($unmatched) { + throw new \CRM_Core_Exception("Malformed token parameters (" . $m[0] . ")"); + } + } return $callback($m[0] ?? NULL, $m[1] ?? NULL, $m[2] ?? NULL, $filterParts); }, $expression); } diff --git a/tests/phpunit/CRM/Core/TokenSmartyTest.php b/tests/phpunit/CRM/Core/TokenSmartyTest.php index 328e312bd4..c05572fa79 100644 --- a/tests/phpunit/CRM/Core/TokenSmartyTest.php +++ b/tests/phpunit/CRM/Core/TokenSmartyTest.php @@ -32,6 +32,26 @@ class CRM_Core_TokenSmartyTest extends CiviUnitTestCase { ['extra' => ['foo' => 'foobar']] ); $this->assertEquals('First name is Bob. ExtraFoo is foobar.', $rendered['msg_subject']); + + try { + $modifiers = [ + '|crmDate:"shortdate"' => '02/01/2020', + '|crmDate:"%B %Y"' => 'February 2020', + '|crmDate' => 'February 1st, 2020 3:04 AM', + ]; + foreach ($modifiers as $modifier => $expected) { + CRM_Utils_Time::setTime('2020-02-01 03:04:05'); + $rendered = CRM_Core_TokenSmarty::render( + ['msg_subject' => "Now is the token, {domain.now$modifier}! No, now is the smarty-pants, {\$extra.now$modifier}!"], + ['contactId' => $this->contactId], + ['extra' => ['now' => '2020-02-01 03:04:05']] + ); + $this->assertEquals("Now is the token, $expected! No, now is the smarty-pants, $expected!", $rendered['msg_subject']); + } + } + finally { + \CRM_Utils_Time::resetTime(); + } } /** diff --git a/tests/phpunit/CRM/Utils/TokenConsistencyTest.php b/tests/phpunit/CRM/Utils/TokenConsistencyTest.php index 10a22888be..ebfb594083 100644 --- a/tests/phpunit/CRM/Utils/TokenConsistencyTest.php +++ b/tests/phpunit/CRM/Utils/TokenConsistencyTest.php @@ -522,14 +522,15 @@ December 21st, 2007 CRM_Utils_Time::setTime('2021-09-18 23:58:00'); $modifiers = [ 'shortdate' => '09/18/2021', + '%B %Y' => 'September 2021', ]; foreach ($modifiers as $filter => $expected) { $resolved = CRM_Core_BAO_MessageTemplate::renderTemplate([ 'messageTemplate' => [ - 'msg_text' => '{domain.now|crmDate:' . $filter . '}', + 'msg_text' => '{domain.now|crmDate:"' . $filter . '"}', ], ])['text']; - $this->assertEquals('09/18/2021', $resolved); + $this->assertEquals($expected, $resolved); } $resolved = CRM_Core_BAO_MessageTemplate::renderTemplate([ 'messageTemplate' => [ @@ -537,6 +538,19 @@ December 21st, 2007 ], ])['text']; $this->assertEquals('September 18th, 2021 11:58 PM', $resolved); + + // This example is malformed - no quotes + try { + $resolved = CRM_Core_BAO_MessageTemplate::renderTemplate([ + 'messageTemplate' => [ + 'msg_text' => '{domain.now|crmDate:shortdate}', + ], + ])['text']; + $this->fail("Expected unquoted parameter to fail"); + } + catch (\CRM_Core_Exception $e) { + $this->assertRegExp(';Malformed token param;', $e->getMessage()); + } } /** diff --git a/tests/phpunit/Civi/Token/TokenProcessorTest.php b/tests/phpunit/Civi/Token/TokenProcessorTest.php index 61a4f486de..3c9890c420 100644 --- a/tests/phpunit/Civi/Token/TokenProcessorTest.php +++ b/tests/phpunit/Civi/Token/TokenProcessorTest.php @@ -1,6 +1,7 @@ dispatcher, [ + 'controller' => __CLASS__, + ]); + $examples = [ + '{foo.bar}' => ['foo', 'bar', NULL], + '{foo.bar|whiz}' => ['foo', 'bar', ['whiz']], + '{foo.bar|whiz:"bang"}' => ['foo', 'bar', ['whiz', 'bang']], + '{love.shack|place:"bang":"b@ng, on +he/([do0r])?!"}' => ['love', 'shack', ['place', 'bang', 'b@ng, on +he/([do0r])?!']], + ]; + foreach ($examples as $input => $expected) { + array_unshift($expected, $input); + $log = []; + Invasive::call([$p, 'visitTokens'], [ + $input, + function (?string $fullToken, ?string $entity, ?string $field, ?array $modifier) use (&$log) { + $log[] = [$fullToken, $entity, $field, $modifier]; + }, + ]); + $this->assertEquals(1, count($log), "Should receive one callback on expression: $input"); + $this->assertEquals($expected, $log[0]); + } + } + /** * Test that a row can be added via "addRow(array $context)". */ -- 2.25.1