From b7edd04eb0dbcae4419f7073faea923a4140579b Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 23 Feb 2021 01:53:29 -0800 Subject: [PATCH] afform - Add support for `{afform.myFormUrl}` and `{afform.myFormLink}` tokens. Include test. --- ext/afform/core/Civi/Afform/Tokens.php | 204 ++++++++++++++++++++ ext/afform/core/afform.php | 14 +- ext/afform/mock/ang/mockPublicForm.aff.json | 3 +- ext/afform/mock/ang/mockPublicForm.test.php | 113 +++++++++++ 4 files changed, 329 insertions(+), 5 deletions(-) create mode 100644 ext/afform/core/Civi/Afform/Tokens.php diff --git a/ext/afform/core/Civi/Afform/Tokens.php b/ext/afform/core/Civi/Afform/Tokens.php new file mode 100644 index 0000000000..bc12d8477a --- /dev/null +++ b/ext/afform/core/Civi/Afform/Tokens.php @@ -0,0 +1,204 @@ +content) as $field) { + $e->content[$field] = preg_replace(';https?://(\{afform.*Url\});', '$1', $e->content[$field]); + } + } + + /** + * Expose tokens for use in UI. + * + * @param \Civi\Core\Event\GenericHookEvent $e + * @see \CRM_Utils_Hook::tokens() + */ + public static function hook_civicrm_tokens(GenericHookEvent $e) { + $tokenForms = static::getTokenForms(); + foreach ($tokenForms as $tokenName => $afform) { + $e->tokens['afform']["afform.{$tokenName}Url"] = E::ts('%1 (URL)', [1 => $afform['title'] ?? $afform['name']]); + $e->tokens['afform']["afform.{$tokenName}Link"] = E::ts('%1 (Full Hyperlink)', [1 => $afform['title'] ?? $afform['name']]); + } + } + + /** + * Substitute any tokens of the form `{afform.myFormUrl}` or `{afform.myFormLink}` with actual values. + * + * @param \Civi\Core\Event\GenericHookEvent $e + * @see \CRM_Utils_Hook::tokenValues() + */ + public static function hook_civicrm_tokenValues(GenericHookEvent $e) { + try { + // Depending on the caller, $tokens['afform'] might be ['fooUrl'] or ['fooUrl'=>1]. Because... why not! + $activeAfformTokens = array_merge(array_keys($e->tokens['afform'] ?? []), array_values($e->tokens['afform'] ?? [])); + + $tokenForms = static::getTokenForms(); + foreach ($tokenForms as $formName => $afform) { + if (!array_intersect($activeAfformTokens, ["{$formName}Url", "{$formName}Link"])) { + continue; + } + + if (empty($afform['server_route'])) { + continue; + } + + if (!is_array($e->contactIDs)) { + $url = self::createUrl($afform, $e->contactIDs); + $e->details["afform.{$formName}Url"] = $url; + $e->details["afform.{$formName}Link"] = sprintf('%s', htmlentities($url), htmlentities($afform['title'] ?? $afform['name'])); + } + else { + foreach ($e->contactIDs as $cid) { + $url = self::createUrl($afform, $cid); + $e->details[$cid]["afform.{$formName}Url"] = $url; + $e->details[$cid]["afform.{$formName}Link"] = sprintf('%s', htmlentities($url), htmlentities($afform['title'] ?? $afform['name'])); + } + } + } + } + catch (CryptoException $ex) { + \Civi::log()->warning('Civi\Afform\LegacyTokens cannot generate tokens due to crypto exception.', ['exception' => $ex]); + } + } + + ///** + // * Expose tokens for use in UI. + // * + // * @param \Civi\Token\Event\TokenRegisterEvent $e + // */ + //public static function onRegister(\Civi\Token\Event\TokenRegisterEvent $e) { + // $tokenForms = static::getTokenForms(); + // foreach ($tokenForms as $tokenName => $afform) { + // $e->register([ + // 'entity' => 'afform', + // 'field' => $tokenName . 'Url', + // 'label' => E::ts('View Form: %1 (URL)', [1 => $afform['title'] ?? $afform['name']]), + // ]); + // $e->register([ + // 'entity' => 'afform', + // 'field' => $tokenName . 'Link', + // 'label' => E::ts('View Form: %1 (Full Hyperlink)', [1 => $afform['title'] ?? $afform['name']]), + // ]); + // } + //} + + ///** + // * Substitute any tokens of the form `{afform.myFormUrl}` or `{afform.myFormLink}` with actual values. + // * + // * @param \Civi\Token\Event\TokenValueEvent $e + // */ + //public static function onEvaluate(\Civi\Token\Event\TokenValueEvent $e) { + // $activeTokens = $e->getTokenProcessor()->getMessageTokens(); + // if (empty($activeTokens['afform'])) { + // return; + // } + // + // $tokenForms = static::getTokenForms(); + // foreach ($tokenForms as $formName => $afform) { + // if (!array_intersect($activeTokens['afform'], ["{$formName}Url", "{$formName}Link"])) { + // continue; + // } + // + // if (empty($afform['server_route'])) { + // \Civi::log() + // ->warning('Civi\Afform\Tokens: Cannot generate link for {formName} -- missing server_route', [ + // 'formName' => $formName, + // ]); + // continue; + // } + // + // foreach ($e->getRows() as $row) { + // /** @var \Civi\Token\TokenRow $row */ + // try { + // $url = self::createUrl($afform, $row->context['contactId']); + // $row->format('text/plain')->tokens('afform', "{$formName}Url", $url); + // $row->format('text/html')->tokens('afform', "{$formName}Link", + // sprintf('%s', htmlentities($url), htmlentities($afform['title'] ?? $afform['name']))); + // } + // catch (CryptoException $e) { + // \Civi::log()->warning('Civi\Afform\Tokens cannot generate tokens due to crypto exception.', ['exception' => $e]); + // } + // } + // } + //} + + /** + * Get a list of forms that have token support enabled. + * + * @return array + * $result[$formName] = ['name' => $formName, 'title' => $formTitle, 'server_route' => $route]; + */ + public static function getTokenForms() { + if (!isset(\Civi::$statics[__CLASS__]['tokenForms'])) { + $tokenForms = (array) \Civi\Api4\Afform::get(0) + ->addWhere('is_token', '=', TRUE) + ->addSelect('name', 'title', 'server_route', 'is_public') + ->execute() + ->indexBy('name'); + \Civi::$statics[__CLASS__]['tokenForms'] = $tokenForms; + } + return \Civi::$statics[__CLASS__]['tokenForms']; + } + + /** + * Generate an authenticated URL for viewing this form. + * + * @param array $afform + * @param int $contactId + * + * @return string + * @throws \Civi\Crypto\Exception\CryptoException + */ + public static function createUrl($afform, $contactId): string { + $expires = \CRM_Utils_Time::time() + + (\Civi::settings()->get('checksum_timeout') * 24 * 60 * 60); + + /** @var \Civi\Crypto\CryptoJwt $jwt */ + $jwt = \Civi::service('crypto.jwt'); + + $bearerToken = "Bearer " . $jwt->encode([ + 'exp' => $expires, + 'sub' => "cid:" . $contactId, + 'scope' => 'authx', + ]); + + $url = \CRM_Utils_System::url($afform['server_route'], + ['_authx' => $bearerToken, '_authxSes' => 1], + TRUE, + NULL, + FALSE, + $afform['is_public'] ?? TRUE + ); + return $url; + } + +} diff --git a/ext/afform/core/afform.php b/ext/afform/core/afform.php index e34c558922..717249af6c 100644 --- a/ext/afform/core/afform.php +++ b/ext/afform/core/afform.php @@ -49,10 +49,16 @@ function afform_civicrm_config(&$config) { } Civi::$statics[__FUNCTION__] = 1; - Civi::dispatcher()->addListener(Submit::EVENT_NAME, [Submit::class, 'processContacts'], 500); - Civi::dispatcher()->addListener(Submit::EVENT_NAME, [Submit::class, 'processGenericEntity'], -1000); - Civi::dispatcher()->addListener('hook_civicrm_angularModules', ['\Civi\Afform\AngularDependencyMapper', 'autoReq'], -1000); - Civi::dispatcher()->addListener('hook_civicrm_alterAngular', ['\Civi\Afform\AfformMetadataInjector', 'preprocess']); + $dispatcher = Civi::dispatcher(); + $dispatcher->addListener(Submit::EVENT_NAME, [Submit::class, 'processContacts'], 500); + $dispatcher->addListener(Submit::EVENT_NAME, [Submit::class, 'processGenericEntity'], -1000); + $dispatcher->addListener('hook_civicrm_angularModules', ['\Civi\Afform\AngularDependencyMapper', 'autoReq'], -1000); + $dispatcher->addListener('hook_civicrm_alterAngular', ['\Civi\Afform\AfformMetadataInjector', 'preprocess']); + + // Register support for email tokens + $dispatcher->addListener('hook_civicrm_alterMailContent', ['\Civi\Afform\Tokens', 'applyCkeditorWorkaround']); + $dispatcher->addListener('hook_civicrm_tokens', ['\Civi\Afform\Tokens', 'hook_civicrm_tokens']); + $dispatcher->addListener('hook_civicrm_tokenValues', ['\Civi\Afform\Tokens', 'hook_civicrm_tokenValues']); } /** diff --git a/ext/afform/mock/ang/mockPublicForm.aff.json b/ext/afform/mock/ang/mockPublicForm.aff.json index e97ef09b11..eb221305ee 100644 --- a/ext/afform/mock/ang/mockPublicForm.aff.json +++ b/ext/afform/mock/ang/mockPublicForm.aff.json @@ -2,5 +2,6 @@ "type": "form", "title": "My public form", "server_route": "civicrm/mock-public-form", - "permission": "*always allow*" + "permission": "*always allow*", + "is_token": true } diff --git a/ext/afform/mock/ang/mockPublicForm.test.php b/ext/afform/mock/ang/mockPublicForm.test.php index d4f7bf4f0c..70feb1f6a2 100644 --- a/ext/afform/mock/ang/mockPublicForm.test.php +++ b/ext/afform/mock/ang/mockPublicForm.test.php @@ -63,4 +63,117 @@ class MockPublicFormTest extends \Civi\AfformMock\FormTestCase { $this->assertEquals(0, CRM_Core_DAO::singleValueQuery('SELECT count(*) FROM civicrm_contact WHERE first_name=%1', [1 => ["Firsty{$r}", 'String']])); } + /** + * The email token `{afform.mockPublicFormUrl}` should evaluate to an authenticated URL. + */ + public function testAuthenticatedUrlToken_Plain() { + if (!function_exists('authx_civicrm_config')) { + $this->fail('Cannot test without authx'); + } + + $lebowski = $this->getLebowskiCID(); + $text = $this->renderTokens($lebowski, 'Please go to {afform.mockPublicFormUrl}', 'text/plain'); + if (!preg_match(';Please go to ([^\s]+);', $text, $m)) { + $this->fail('Plain text message did not have URL in expected place: ' . $text); + } + $url = $m[1]; + $this->assertRegExp(';^https?:.*civicrm/mock-public-form.*;', $url, "URL should look plausible"); + + // Going to this page will cause us to authenticate as the target contact + $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => new \GuzzleHttp\Cookie\CookieJar()]); + $response = $http->get($url); + $r = (string) $response->getBody(); + $this->assertStatusCode(200, $response); + $response = $http->get('civicrm/authx/id'); + $this->assertContactJson($lebowski, $response); + } + + /** + * The email token `{afform.mockPublicFormUrl}` should evaluate to an authenticated URL. + */ + public function testAuthenticatedUrlToken_Html() { + if (!function_exists('authx_civicrm_config')) { + $this->fail('Cannot test without authx'); + } + + $lebowski = $this->getLebowskiCID(); + $html = $this->renderTokens($lebowski, 'Please go to my form', 'text/html'); + + if (!preg_match(';a href="([^"]+)";', $html, $m)) { + $this->fail('HTML message did not have URL in expected place: ' . $html); + } + $url = html_entity_decode($m[1]); + $this->assertRegExp(';^https?:.*civicrm/mock-public-form.*;', $url, "URL should look plausible"); + + // Going to this page will cause us to authenticate as the target contact + $http = $this->createGuzzle(['cookies' => new \GuzzleHttp\Cookie\CookieJar()]); + $response = $http->get($url); + $this->assertStatusCode(200, $response); + $response = $http->get('civicrm/authx/id'); + $this->assertContactJson($lebowski, $response); + } + + /** + * The email token `{afform.mockPublicFormLink}` should evaluate to an authenticated URL. + */ + public function testAuthenticatedLinkToken_Html() { + if (!function_exists('authx_civicrm_config')) { + $this->fail('Cannot test without authx'); + } + + $lebowski = $this->getLebowskiCID(); + $html = $this->renderTokens($lebowski, 'Please go to {afform.mockPublicFormLink}', 'text/html'); + $doc = \phpQuery::newDocument($html, 'text/html'); + $this->assertEquals(1, $doc->find('a')->count(), 'Document should have hyperlink'); + foreach ($doc->find('a') as $item) { + /** @var \DOMElement $item */ + $this->assertRegExp(';^https?:.*civicrm/mock-public-form.*;', $item->getAttribute('href')); + $this->assertEquals('My public form', $item->firstChild->data); + $url = $item->getAttribute('href'); + } + + // Going to this page will cause us to authenticate as the target contact + $http = $this->createGuzzle(['cookies' => new \GuzzleHttp\Cookie\CookieJar()]); + $response = $http->get($url); + $this->assertStatusCode(200, $response); + $response = $http->get('civicrm/authx/id'); + $this->assertContactJson($lebowski, $response); + } + + protected function renderTokens($cid, $body, $format) { + $tp = new \Civi\Token\TokenProcessor(\Civi::dispatcher(), []); + $tp->addRow()->context('contactId', $cid); + $tp->addMessage('example', $body, $format); + $tp->evaluate(); + return $tp->getRow(0)->render('example'); + } + + protected function getLebowskiCID() { + $contact = \civicrm_api3('Contact', 'create', [ + 'contact_type' => 'Individual', + 'first_name' => 'Jeffrey', + 'last_name' => 'Lebowski', + 'external_identifier' => __CLASS__, + 'options' => [ + 'match' => 'external_identifier', + ], + ]); + return $contact['id']; + } + + /** + * Assert the AJAX request provided the expected contact. + * + * @param int $cid + * The expected contact ID + * @param \Psr\Http\Message\ResponseInterface $response + */ + public function assertContactJson($cid, $response) { + $this->assertContentType('application/json', $response); + $this->assertStatusCode(200, $response); + $j = json_decode((string) $response->getBody(), 1); + $formattedFailure = $this->formatFailure($response); + $this->assertEquals($cid, $j['contact_id'], "Response did not give expected contact ID\n" . $formattedFailure); + } + } -- 2.25.1