From 87e3fe242fd10ba4d4a3f43c2aea05657c405489 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Thu, 30 Mar 2017 22:55:47 -0700 Subject: [PATCH] CRM-20600 - Implement AssetBuilder - See extended docblock in AssetBuilder.php --- CRM/Core/xml/Menu/Misc.xml | 5 + CRM/Utils/Hook.php | 24 ++ Civi/Core/AssetBuilder.php | 350 ++++++++++++++++++ Civi/Core/Container.php | 5 + Civi/Core/Exception/UnknownAssetException.php | 6 + tests/phpunit/E2E/Core/AssetBuilderTest.php | 170 +++++++++ 6 files changed, 560 insertions(+) create mode 100644 Civi/Core/AssetBuilder.php create mode 100644 Civi/Core/Exception/UnknownAssetException.php create mode 100644 tests/phpunit/E2E/Core/AssetBuilderTest.php diff --git a/CRM/Core/xml/Menu/Misc.xml b/CRM/Core/xml/Menu/Misc.xml index 14195441b5..523fe37f29 100644 --- a/CRM/Core/xml/Menu/Misc.xml +++ b/CRM/Core/xml/Menu/Misc.xml @@ -121,6 +121,11 @@ CRM_Contribute_Form_ContributionCharts access CiviCRM + + civicrm/asset/builder + \Civi\Core\AssetBuilder::pageRun + *always allow* + civicrm/contribute/ajax/tableview CRM_Contribute_Page_DashBoard diff --git a/CRM/Utils/Hook.php b/CRM/Utils/Hook.php index e69cbb77ab..d44d2dacdd 100644 --- a/CRM/Utils/Hook.php +++ b/CRM/Utils/Hook.php @@ -2101,6 +2101,30 @@ abstract class CRM_Utils_Hook { ); } + /** + * This hook is called whenever the system builds a new copy of + * semi-static asset. + * + * @param string $asset + * The name of the asset. + * Ex: 'angular.json' + * @param array $params + * List of optional arguments which influence the content. + * Note: Params are immutable because they are part of the cache-key. + * @param string $mimeType + * Initially, NULL. Modify to specify the mime-type. + * @param string $content + * Initially, NULL. Modify to specify the rendered content. + * @return null + * the return value is ignored + */ + public static function buildAsset($asset, $params, &$mimeType, &$content) { + return self::singleton()->invoke(array('asset', 'params', 'mimeType', 'content'), + $asset, $params, $mimeType, $content, self::$_nullObject, self::$_nullObject, + 'civicrm_buildAsset' + ); + } + /** * This hook fires whenever a record in a case changes. * diff --git a/Civi/Core/AssetBuilder.php b/Civi/Core/AssetBuilder.php new file mode 100644 index 0000000000..f213e7a00d --- /dev/null +++ b/Civi/Core/AssetBuilder.php @@ -0,0 +1,350 @@ +getUrl('api-fields.json'); + * + * // Define the content of `api-fields.json`. + * function hook_civicrm_buildAsset($asset, $params, &$mimeType, &$content) { + * if ($asset !== 'api-fields.json') return; + * + * $entities = civicrm_api3('Entity', 'get', array()); + * $fields = array(); + * foreach ($entities['values'] as $entity) { + * $fields[$entity] = civicrm_api3($entity, 'getfields'); + * } + * + * $mimeType = 'application/json'; + * $content = json_encode($fields); + * } + * @endCode + * + * Assets can be parameterized. Each combination of ($asset,$params) + * will be cached separately. For example, we might want a copy of + * 'api-fields.json' which only includes a handful of chosen entities. + * Simply pass the chosen entities into `getUrl()`, then update + * the definition to use `$params['entities']`, as in: + * + * @code + * // Build a URL to `api-fields.json`. + * $url = \Civi::service('asset_builder')->getUrl('api-fields.json', array( + * 'entities' => array('Contact', 'Phone', 'Email', 'Address'), + * )); + * + * // Define the content of `api-fields.json`. + * function hook_civicrm_buildAsset($asset, $params, &$mimeType, &$content) { + * if ($asset !== 'api-fields.json') return; + * + * $fields = array(); + * foreach ($params['entities'] as $entity) { + * $fields[$entity] = civicrm_api3($entity, 'getfields'); + * } + * + * $mimeType = 'application/json'; + * $content = json_encode($fields); + * } + * @endCode + * + * Note: These assets are designed to hold non-sensitive data, such as + * aggregated JS or common metadata. There probably are ways to + * secure it (e.g. alternative digest() calculations), but the + * current implementation is KISS. + */ +class AssetBuilder { + + protected $cacheEnabled; + + /** + * AssetBuilder constructor. + * @param $cacheEnabled + */ + public function __construct($cacheEnabled = NULL) { + if ($cacheEnabled === NULL) { + $cacheEnabled = !\CRM_Core_Config::singleton()->debug; + } + $this->cacheEnabled = $cacheEnabled; + } + + /** + * Determine if $name is a well-formed asset name. + * + * @param string $name + * @return bool + */ + public function isValidName($name) { + return preg_match(';^[a-zA-Z0-9\.\-_/]+$;', $name) + && strpos($name, '..') === FALSE + && strpos($name, '.') !== FALSE; + } + + /** + * @param string $name + * Ex: 'angular.json'. + * @param array $params + * @return string + * URL. + * Ex: 'http://example.org/files/civicrm/dyn/angular.abcd1234abcd1234.json'. + */ + public function getUrl($name, $params = array()) { + if (!$this->isValidName($name)) { + throw new \RuntimeException("Invalid dynamic asset name"); + } + + if ($this->isCacheEnabled()) { + $fileName = $this->build($name, $params); + return $this->getCacheUrl($fileName); + } + else { + return \CRM_Utils_System::url('civicrm/asset/builder', array( + 'an' => $name, + 'ap' => $this->encode($params), + 'ad' => $this->digest($name, $params), + ), TRUE, NULL, FALSE); + } + } + + /** + * Build the cached copy of an $asset. + * + * @param string $name + * Ex: 'angular.json'. + * @param array $params + * @param bool $force + * Build the asset anew, even if it already exists. + * @return string + * File name (relative to cache folder). + * Ex: 'angular.abcd1234abcd1234.json'. + * @throws UnknownAssetException + */ + public function build($name, $params, $force = FALSE) { + if (!$this->isValidName($name)) { + throw new UnknownAssetException("Asset name is malformed"); + } + $nameParts = explode('.', $name); + array_splice($nameParts, -1, 0, array($this->digest($name, $params))); + $fileName = implode('.', $nameParts); + if ($force || !file_exists($this->getCachePath($fileName))) { + // No file locking, but concurrent writers should produce + // the same data, so we'll just plow ahead. + + if (!file_exists($this->getCachePath())) { + mkdir($this->getCachePath()); + } + + $rendered = $this->render($name, $params); + file_put_contents($this->getCachePath($fileName), $rendered['content']); + return $fileName; + } + return $fileName; + } + + /** + * Generate the content for a dynamic asset. + * + * @param string $name + * @param array $params + * @return array + * Array with keys: + * - statusCode: int, ex: 200. + * - mimeType: string, ex: 'text/html'. + * - content: string, ex: 'Hello world'. + * @throws \CRM_Core_Exception + */ + public function render($name, $params = array()) { + if (!$this->isValidName($name)) { + throw new UnknownAssetException("Asset name is malformed"); + } + \CRM_Utils_Hook::buildAsset($name, $params, $mimeType, $content); + if ($mimeType === NULL && $content === NULL) { + throw new UnknownAssetException("Unrecognized asset name: $name"); + } + // Beg your pardon, sir. Please may I have an HTTP response class instead? + return array( + 'statusCode' => 200, + 'mimeType' => $mimeType, + 'content' => $content, + ); + } + + /** + * Clear out any cache files. + */ + public function clear() { + \CRM_Utils_File::cleanDir($this->getCachePath()); + } + + /** + * Determine the local path of a cache file. + * + * @param string|NULL $fileName + * Ex: 'angular.abcd1234abcd1234.json'. + * @return string + * URL. + * Ex: '/var/www/files/civicrm/dyn/angular.abcd1234abcd1234.json'. + */ + protected function getCachePath($fileName = NULL) { + // imageUploadDir has the correct functional properties but a wonky name. + $suffix = ($fileName === NULL) ? '' : (DIRECTORY_SEPARATOR . $fileName); + return + \CRM_Utils_File::addTrailingSlash(\CRM_Core_Config::singleton()->imageUploadDir) + . 'dyn' . $suffix; + } + + /** + * Determine the URL of a cache file. + * + * @param string|NULL $fileName + * Ex: 'angular.abcd1234abcd1234.json'. + * @return string + * URL. + * Ex: 'http://example.org/files/civicrm/dyn/angular.abcd1234abcd1234.json'. + */ + protected function getCacheUrl($fileName = NULL) { + // imageUploadURL has the correct functional properties but a wonky name. + $suffix = ($fileName === NULL) ? '' : ('/' . $fileName); + return + \CRM_Utils_File::addTrailingSlash(\CRM_Core_Config::singleton()->imageUploadURL, '/') + . 'dyn' . $suffix; + } + + /** + * Create a unique identifier for the $params. + * + * This identifier is designed to avoid accidental cache collisions. + * + * @param string $name + * @param array $params + * @return string + */ + protected function digest($name, $params) { + // WISHLIST: For secure digest, generate+persist privatekey & call hash_hmac. + ksort($params); + $digest = md5( + $name . + \CRM_Core_Resources::singleton()->getCacheCode() . + \CRM_Core_Config_Runtime::getId() . + json_encode($params) + ); + return $digest; + } + + /** + * Encode $params in a format that's optimized for shorter URLs. + * + * @param array $params + * @return string + */ + protected function encode($params) { + if (empty($params)) { + return ''; + } + + $str = json_encode($params); + if (function_exists('gzdeflate')) { + $str = gzdeflate($str); + } + return base64_encode($str); + } + + /** + * @param string $str + * @return array + */ + protected function decode($str) { + if ($str === NULL || $str === FALSE || $str === '') { + return array(); + } + + $str = base64_decode($str); + if (function_exists('gzdeflate')) { + $str = gzinflate($str); + } + return json_decode($str, TRUE); + } + + /** + * @return bool + */ + public function isCacheEnabled() { + return $this->cacheEnabled; + } + + /** + * @param bool|null $cacheEnabled + * @return AssetBuilder + */ + public function setCacheEnabled($cacheEnabled) { + $this->cacheEnabled = $cacheEnabled; + return $this; + } + + /** + * (INTERNAL ONLY) + * + * Execute a page-request for `civicrm/asset/builder`. + */ + public static function pageRun() { + // Beg your pardon, sir. Please may I have an HTTP response class instead? + $asset = self::pageRender($_GET); + if (function_exists('http_response_code')) { + // PHP 5.4+ + http_response_code($asset['statusCode']); + } + else { + header('X-PHP-Response-Code: ' . $asset['statusCode'], TRUE, $asset['statusCode']); + } + + header('Content-Type: ' . $asset['mimeType']); + echo $asset['content']; + \CRM_Utils_System::civiExit(); + } + + /** + * (INTERNAL ONLY) + * + * Execute a page-request for `civicrm/asset/builder`. + * + * @param array $get + * The _GET values. + * @return array + * Array with keys: + * - statusCode: int, ex 200. + * - mimeType: string, ex 'text/html'. + * - content: string, ex 'Hello world'. + */ + public static function pageRender($get) { + // Beg your pardon, sir. Please may I have an HTTP response class instead? + try { + $assets = \Civi::service('asset_builder'); + return $assets->render($get['an'], $assets->decode($get['ap'])); + } + catch (UnknownAssetException $e) { + return array( + 'statusCode' => 404, + 'mimeType' => 'text/plain', + 'content' => $e->getMessage(), + ); + } + } + +} diff --git a/Civi/Core/Container.php b/Civi/Core/Container.php index 97e23379cf..24bf7e0f8c 100644 --- a/Civi/Core/Container.php +++ b/Civi/Core/Container.php @@ -178,6 +178,11 @@ class Container { array() )); + $container->setDefinition('asset_builder', new Definition( + 'Civi\Core\AssetBuilder', + array() + )); + $container->setDefinition('pear_mail', new Definition('Mail')) ->setFactoryClass('CRM_Utils_Mail')->setFactoryMethod('createMailer'); diff --git a/Civi/Core/Exception/UnknownAssetException.php b/Civi/Core/Exception/UnknownAssetException.php new file mode 100644 index 0000000000..1d23598109 --- /dev/null +++ b/Civi/Core/Exception/UnknownAssetException.php @@ -0,0 +1,6 @@ +clear(); + + $this->fired['hook_civicrm_buildAsset'] = 0; + \Civi::dispatcher()->addListener('hook_civicrm_buildAsset', array($this, 'counter')); + \Civi::dispatcher()->addListener('hook_civicrm_buildAsset', array($this, 'buildSquareTxt')); + \Civi::dispatcher()->addListener('hook_civicrm_buildAsset', array($this, 'buildSquareJs')); + } + + /** + * @param \Civi\Core\Event\GenericHookEvent $e + * @see \CRM_Utils_Hook::buildAsset() + */ + public function counter(GenericHookEvent $e) { + $this->fired['hook_civicrm_buildAsset']++; + } + + /** + * @param \Civi\Core\Event\GenericHookEvent $e + * @see \CRM_Utils_Hook::buildAsset() + */ + public function buildSquareTxt(GenericHookEvent $e) { + if ($e->asset !== 'square.txt') { + return; + } + $this->assertTrue(in_array($e->params['x'], array(11, 12))); + + $e->mimeType = 'text/plain'; + $e->content = "Square: " . ($e->params['x'] * $e->params['x']); + } + + /** + * @param \Civi\Core\Event\GenericHookEvent $e + * @see \CRM_Utils_Hook::buildAsset() + */ + public function buildSquareJs(GenericHookEvent $e) { + if ($e->asset !== 'square.js') { + return; + } + $this->assertTrue(in_array($e->params['x'], array(11, 12))); + + $e->mimeType = 'application/javascript'; + $e->content = "var square=" . ($e->params['x'] * $e->params['x']) . ';'; + } + + /** + * Get a list of example assets to build/request. + * @return array + */ + public function getExamples() { + $examples = array(); + + $examples[] = array( + 0 => 'square.txt', + 1 => array('x' => 11), + 2 => 'text/plain', + 3 => 'Square: 121', + ); + $examples[] = array( + 0 => 'square.txt', + 1 => array('x' => 12), + 2 => 'text/plain', + 3 => 'Square: 144', + ); + $examples[] = array( + 0 => 'square.js', + 1 => array('x' => 12), + 2 => 'application/javascript', + 3 => 'var square=144;', + ); + + return $examples; + } + + /** + * @param string $asset + * Ex: 'square.txt'. + * @param array $params + * Ex: [x=>12]. + * @param string $expectedMimeType + * Ex: 'text/plain'. + * @param string $expectedContent + * Ex: 'Square: 144'. + * @dataProvider getExamples + */ + public function testRender($asset, $params, $expectedMimeType, $expectedContent) { + $asset = \Civi::service('asset_builder')->render($asset, $params); + $this->assertEquals(1, $this->fired['hook_civicrm_buildAsset']); + $this->assertEquals($expectedMimeType, $asset['mimeType']); + $this->assertEquals($expectedContent, $asset['content']); + } + + /** + * @param string $asset + * Ex: 'square.txt'. + * @param array $params + * Ex: [x=>12]. + * @param string $expectedMimeType + * Ex: 'text/plain'. + * @param string $expectedContent + * Ex: 'Square: 144'. + * @dataProvider getExamples + */ + public function testGetUrl_cached($asset, $params, $expectedMimeType, $expectedContent) { + \Civi::service('asset_builder')->setCacheEnabled(TRUE); + $url = \Civi::service('asset_builder')->getUrl($asset, $params); + $this->assertEquals(1, $this->fired['hook_civicrm_buildAsset']); + $this->assertRegExp(';^https?:.*dyn/square.[0-9a-f]+.(txt|js)$;', $url); + $this->assertEquals($expectedContent, file_get_contents($url)); + // Note: This actually relies on httpd to determine MIME type. + // That could be ambiguous for javascript. + $this->assertContains("Content-Type: $expectedMimeType", $http_response_header); + $this->assertNotEmpty(preg_grep(';HTTP/1.1 200;', $http_response_header)); + } + + /** + * @param string $asset + * Ex: 'square.txt'. + * @param array $params + * Ex: [x=>12]. + * @param string $expectedMimeType + * Ex: 'text/plain'. + * @param string $expectedContent + * Ex: 'Square: 144'. + * @dataProvider getExamples + */ + public function testGetUrl_uncached($asset, $params, $expectedMimeType, $expectedContent) { + \Civi::service('asset_builder')->setCacheEnabled(FALSE); + $url = \Civi::service('asset_builder')->getUrl($asset, $params); + $this->assertEquals(0, $this->fired['hook_civicrm_buildAsset']); + $this->assertRegExp(';^https?:.*civicrm/asset/builder.*square.(txt|js);', $url); + + // Simulate a request. Our fake hook won't fire in a real request. + parse_str(parse_url($url, PHP_URL_QUERY), $get); + $asset = AssetBuilder::pageRender($get); + $this->assertEquals($expectedMimeType, $asset['mimeType']); + $this->assertEquals($expectedContent, $asset['content']); + } + + public function testInvalid() { + \Civi::service('asset_builder')->setCacheEnabled(FALSE); + $url = \Civi::service('asset_builder')->getUrl('invalid.json'); + $this->assertEmpty(file_get_contents($url)); + $this->assertNotEmpty(preg_grep(';HTTP/1.1 404;', $http_response_header), + 'Expect to find HTTP 404. Found: ' . json_encode(preg_grep(';^HTTP;', $http_response_header))); + } + +} -- 2.25.1