| 1 | <?php |
| 2 | |
| 3 | namespace Civi\Core; |
| 4 | |
| 5 | use Civi\Core\Exception\UnknownAssetException; |
| 6 | |
| 7 | /** |
| 8 | * Class AssetBuilder |
| 9 | * @package Civi\Core |
| 10 | * |
| 11 | * The AssetBuilder is used to manage semi-dynamic assets. |
| 12 | * In normal production use, these assets are built on first |
| 13 | * reference and then stored in a public-facing cache folder. |
| 14 | * (In debug mode, these assets are constructed during every request.) |
| 15 | * |
| 16 | * There are generally two aspects to usage -- creating a URL |
| 17 | * for the asset, and defining the content of the asset. |
| 18 | * |
| 19 | * For example, suppose we wanted to define a static file |
| 20 | * named "api-fields.json" which lists all the fields of |
| 21 | * all the API entities. |
| 22 | * |
| 23 | * ``` |
| 24 | * // Build a URL to `api-fields.json`. |
| 25 | * $url = \Civi::service('asset_builder')->getUrl('api-fields.json'); |
| 26 | * |
| 27 | * // Define the content of `api-fields.json`. |
| 28 | * function hook_civicrm_buildAsset($asset, $params, &$mimeType, &$content) { |
| 29 | * if ($asset !== 'api-fields.json') return; |
| 30 | * |
| 31 | * $entities = civicrm_api3('Entity', 'get', array()); |
| 32 | * $fields = array(); |
| 33 | * foreach ($entities['values'] as $entity) { |
| 34 | * $fields[$entity] = civicrm_api3($entity, 'getfields'); |
| 35 | * } |
| 36 | * |
| 37 | * $mimeType = 'application/json'; |
| 38 | * $content = json_encode($fields); |
| 39 | * } |
| 40 | * ``` |
| 41 | * |
| 42 | * Assets can be parameterized. Each combination of ($asset,$params) |
| 43 | * will be cached separately. For example, we might want a copy of |
| 44 | * 'api-fields.json' which only includes a handful of chosen entities. |
| 45 | * Simply pass the chosen entities into `getUrl()`, then update |
| 46 | * the definition to use `$params['entities']`, as in: |
| 47 | * |
| 48 | * ``` |
| 49 | * // Build a URL to `api-fields.json`. |
| 50 | * $url = \Civi::service('asset_builder')->getUrl('api-fields.json', array( |
| 51 | * 'entities' => array('Contact', 'Phone', 'Email', 'Address'), |
| 52 | * )); |
| 53 | * |
| 54 | * // Define the content of `api-fields.json`. |
| 55 | * function hook_civicrm_buildAsset($asset, $params, &$mimeType, &$content) { |
| 56 | * if ($asset !== 'api-fields.json') return; |
| 57 | * |
| 58 | * $fields = array(); |
| 59 | * foreach ($params['entities'] as $entity) { |
| 60 | * $fields[$entity] = civicrm_api3($entity, 'getfields'); |
| 61 | * } |
| 62 | * |
| 63 | * $mimeType = 'application/json'; |
| 64 | * $content = json_encode($fields); |
| 65 | * } |
| 66 | * ``` |
| 67 | * |
| 68 | * Note: These assets are designed to hold non-sensitive data, such as |
| 69 | * aggregated JS or common metadata. There probably are ways to |
| 70 | * secure it (e.g. alternative digest() calculations), but the |
| 71 | * current implementation is KISS. |
| 72 | */ |
| 73 | class AssetBuilder { |
| 74 | |
| 75 | /** |
| 76 | * @return array |
| 77 | * Array(string $value => string $label). |
| 78 | */ |
| 79 | public static function getCacheModes() { |
| 80 | return [ |
| 81 | '0' => ts('Disable'), |
| 82 | '1' => ts('Enable'), |
| 83 | 'auto' => ts('Auto'), |
| 84 | ]; |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * @var mixed |
| 89 | */ |
| 90 | protected $cacheEnabled; |
| 91 | |
| 92 | /** |
| 93 | * AssetBuilder constructor. |
| 94 | * @param $cacheEnabled |
| 95 | */ |
| 96 | public function __construct($cacheEnabled = NULL) { |
| 97 | if ($cacheEnabled === NULL) { |
| 98 | $cacheEnabled = \Civi::settings()->get('assetCache'); |
| 99 | if ($cacheEnabled === 'auto') { |
| 100 | $cacheEnabled = !\CRM_Core_Config::singleton()->debug; |
| 101 | } |
| 102 | $cacheEnabled = (bool) $cacheEnabled; |
| 103 | } |
| 104 | $this->cacheEnabled = $cacheEnabled; |
| 105 | } |
| 106 | |
| 107 | /** |
| 108 | * Determine if $name is a well-formed asset name. |
| 109 | * |
| 110 | * @param string $name |
| 111 | * @return bool |
| 112 | */ |
| 113 | public function isValidName($name) { |
| 114 | return preg_match(';^[a-zA-Z0-9\.\-_/]+$;', $name) |
| 115 | && strpos($name, '..') === FALSE |
| 116 | && strpos($name, '.') !== FALSE; |
| 117 | } |
| 118 | |
| 119 | /** |
| 120 | * @param string $name |
| 121 | * Ex: 'angular.json'. |
| 122 | * @param array $params |
| 123 | * @return string |
| 124 | * URL. |
| 125 | * Ex: 'http://example.org/files/civicrm/dyn/angular.abcd1234abcd1234.json'. |
| 126 | */ |
| 127 | public function getUrl($name, $params = []) { |
| 128 | \CRM_Utils_Hook::getAssetUrl($name, $params); |
| 129 | |
| 130 | if (!$this->isValidName($name)) { |
| 131 | throw new \RuntimeException("Invalid dynamic asset name"); |
| 132 | } |
| 133 | |
| 134 | if ($this->isCacheEnabled()) { |
| 135 | $fileName = $this->build($name, $params); |
| 136 | return $this->getCacheUrl($fileName); |
| 137 | } |
| 138 | else { |
| 139 | return \CRM_Utils_System::url('civicrm/asset/builder', [ |
| 140 | 'an' => $name, |
| 141 | 'ap' => $this->encode($params), |
| 142 | 'ad' => $this->digest($name, $params), |
| 143 | ], TRUE, NULL, FALSE); |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | /** |
| 148 | * @param string $name |
| 149 | * Ex: 'angular.json'. |
| 150 | * @param array $params |
| 151 | * @return string |
| 152 | * URL. |
| 153 | * Ex: '/var/www/files/civicrm/dyn/angular.abcd1234abcd1234.json'. |
| 154 | */ |
| 155 | public function getPath($name, $params = []) { |
| 156 | if (!$this->isValidName($name)) { |
| 157 | throw new \RuntimeException("Invalid dynamic asset name"); |
| 158 | } |
| 159 | |
| 160 | $fileName = $this->build($name, $params); |
| 161 | return $this->getCachePath($fileName); |
| 162 | } |
| 163 | |
| 164 | /** |
| 165 | * Build the cached copy of an $asset. |
| 166 | * |
| 167 | * @param string $name |
| 168 | * Ex: 'angular.json'. |
| 169 | * @param array $params |
| 170 | * @param bool $force |
| 171 | * Build the asset anew, even if it already exists. |
| 172 | * @return string |
| 173 | * File name (relative to cache folder). |
| 174 | * Ex: 'angular.abcd1234abcd1234.json'. |
| 175 | * @throws UnknownAssetException |
| 176 | */ |
| 177 | public function build($name, $params, $force = FALSE) { |
| 178 | if (!$this->isValidName($name)) { |
| 179 | throw new UnknownAssetException("Asset name is malformed"); |
| 180 | } |
| 181 | $nameParts = explode('.', $name); |
| 182 | array_splice($nameParts, -1, 0, [$this->digest($name, $params)]); |
| 183 | $fileName = implode('.', $nameParts); |
| 184 | if ($force || !file_exists($this->getCachePath($fileName))) { |
| 185 | // No file locking, but concurrent writers should produce |
| 186 | // the same data, so we'll just plow ahead. |
| 187 | |
| 188 | if (!file_exists($this->getCachePath())) { |
| 189 | mkdir($this->getCachePath()); |
| 190 | } |
| 191 | |
| 192 | $rendered = $this->render($name, $params); |
| 193 | file_put_contents($this->getCachePath($fileName), $rendered['content']); |
| 194 | return $fileName; |
| 195 | } |
| 196 | return $fileName; |
| 197 | } |
| 198 | |
| 199 | /** |
| 200 | * Generate the content for a dynamic asset. |
| 201 | * |
| 202 | * @param string $name |
| 203 | * @param array $params |
| 204 | * @return array |
| 205 | * Array with keys: |
| 206 | * - statusCode: int, ex: 200. |
| 207 | * - mimeType: string, ex: 'text/html'. |
| 208 | * - content: string, ex: '<body>Hello world</body>'. |
| 209 | * @throws \CRM_Core_Exception |
| 210 | */ |
| 211 | public function render($name, $params = []) { |
| 212 | if (!$this->isValidName($name)) { |
| 213 | throw new UnknownAssetException("Asset name is malformed"); |
| 214 | } |
| 215 | \CRM_Utils_Hook::buildAsset($name, $params, $mimeType, $content); |
| 216 | if ($mimeType === NULL && $content === NULL) { |
| 217 | throw new UnknownAssetException("Unrecognized asset name: $name"); |
| 218 | } |
| 219 | // Beg your pardon, sir. Please may I have an HTTP response class instead? |
| 220 | return [ |
| 221 | 'statusCode' => 200, |
| 222 | 'mimeType' => $mimeType, |
| 223 | 'content' => $content, |
| 224 | ]; |
| 225 | } |
| 226 | |
| 227 | /** |
| 228 | * Clear out any cache files. |
| 229 | * |
| 230 | * @param bool $removeDir Should folder itself be removed too. |
| 231 | */ |
| 232 | public function clear($removeDir = TRUE) { |
| 233 | \CRM_Utils_File::cleanDir($this->getCachePath(), $removeDir); |
| 234 | } |
| 235 | |
| 236 | /** |
| 237 | * Determine the local path of a cache file. |
| 238 | * |
| 239 | * @param string|NULL $fileName |
| 240 | * Ex: 'angular.abcd1234abcd1234.json'. |
| 241 | * @return string |
| 242 | * URL. |
| 243 | * Ex: '/var/www/files/civicrm/dyn/angular.abcd1234abcd1234.json'. |
| 244 | */ |
| 245 | protected function getCachePath($fileName = NULL) { |
| 246 | // imageUploadDir has the correct functional properties but a wonky name. |
| 247 | $suffix = ($fileName === NULL) ? '' : (DIRECTORY_SEPARATOR . $fileName); |
| 248 | return \CRM_Utils_File::addTrailingSlash(\CRM_Core_Config::singleton()->imageUploadDir) |
| 249 | . 'dyn' . $suffix; |
| 250 | } |
| 251 | |
| 252 | /** |
| 253 | * Determine the URL of a cache file. |
| 254 | * |
| 255 | * @param string|NULL $fileName |
| 256 | * Ex: 'angular.abcd1234abcd1234.json'. |
| 257 | * @return string |
| 258 | * URL. |
| 259 | * Ex: 'http://example.org/files/civicrm/dyn/angular.abcd1234abcd1234.json'. |
| 260 | */ |
| 261 | protected function getCacheUrl($fileName = NULL) { |
| 262 | // imageUploadURL has the correct functional properties but a wonky name. |
| 263 | $suffix = ($fileName === NULL) ? '' : ('/' . $fileName); |
| 264 | return \CRM_Utils_File::addTrailingSlash(\CRM_Core_Config::singleton()->imageUploadURL, '/') |
| 265 | . 'dyn' . $suffix; |
| 266 | } |
| 267 | |
| 268 | /** |
| 269 | * Create a unique identifier for the $params. |
| 270 | * |
| 271 | * This identifier is designed to avoid accidental cache collisions. |
| 272 | * |
| 273 | * @param string $name |
| 274 | * @param array $params |
| 275 | * @return string |
| 276 | */ |
| 277 | protected function digest($name, $params) { |
| 278 | // WISHLIST: For secure digest, generate+persist privatekey & call hash_hmac. |
| 279 | ksort($params); |
| 280 | $digest = md5( |
| 281 | $name . |
| 282 | \CRM_Core_Resources::singleton()->getCacheCode() . |
| 283 | \CRM_Core_Config_Runtime::getId() . |
| 284 | json_encode($params) |
| 285 | ); |
| 286 | return $digest; |
| 287 | } |
| 288 | |
| 289 | /** |
| 290 | * Encode $params in a format that's optimized for shorter URLs. |
| 291 | * |
| 292 | * @param array $params |
| 293 | * @return string |
| 294 | */ |
| 295 | protected function encode($params) { |
| 296 | if (empty($params)) { |
| 297 | return ''; |
| 298 | } |
| 299 | |
| 300 | $str = json_encode($params); |
| 301 | if (function_exists('gzdeflate')) { |
| 302 | $str = gzdeflate($str); |
| 303 | } |
| 304 | return base64_encode($str); |
| 305 | } |
| 306 | |
| 307 | /** |
| 308 | * @param string $str |
| 309 | * @return array |
| 310 | */ |
| 311 | protected function decode($str) { |
| 312 | if ($str === NULL || $str === FALSE || $str === '') { |
| 313 | return []; |
| 314 | } |
| 315 | |
| 316 | $str = base64_decode($str); |
| 317 | if (function_exists('gzdeflate')) { |
| 318 | $str = gzinflate($str); |
| 319 | } |
| 320 | return json_decode($str, TRUE); |
| 321 | } |
| 322 | |
| 323 | /** |
| 324 | * @return bool |
| 325 | */ |
| 326 | public function isCacheEnabled() { |
| 327 | return $this->cacheEnabled; |
| 328 | } |
| 329 | |
| 330 | /** |
| 331 | * @param bool|null $cacheEnabled |
| 332 | * @return AssetBuilder |
| 333 | */ |
| 334 | public function setCacheEnabled($cacheEnabled) { |
| 335 | $this->cacheEnabled = $cacheEnabled; |
| 336 | return $this; |
| 337 | } |
| 338 | |
| 339 | /** |
| 340 | * (INTERNAL ONLY) |
| 341 | * |
| 342 | * Execute a page-request for `civicrm/asset/builder`. |
| 343 | */ |
| 344 | public static function pageRun() { |
| 345 | // Beg your pardon, sir. Please may I have an HTTP response class instead? |
| 346 | $asset = self::pageRender($_GET); |
| 347 | \CRM_Utils_System::sendResponse(new \GuzzleHttp\Psr7\Response($asset['statusCode'], ['Content-Type' => $asset['mimeType']], $asset['content'])); |
| 348 | } |
| 349 | |
| 350 | /** |
| 351 | * (INTERNAL ONLY) |
| 352 | * |
| 353 | * Execute a page-request for `civicrm/asset/builder`. |
| 354 | * |
| 355 | * @param array $get |
| 356 | * The _GET values. |
| 357 | * @return array |
| 358 | * Array with keys: |
| 359 | * - statusCode: int, ex 200. |
| 360 | * - mimeType: string, ex 'text/html'. |
| 361 | * - content: string, ex '<body>Hello world</body>'. |
| 362 | */ |
| 363 | public static function pageRender($get) { |
| 364 | // Beg your pardon, sir. Please may I have an HTTP response class instead? |
| 365 | try { |
| 366 | $assets = \Civi::service('asset_builder'); |
| 367 | return $assets->render($get['an'], $assets->decode($get['ap'])); |
| 368 | } |
| 369 | catch (UnknownAssetException $e) { |
| 370 | return [ |
| 371 | 'statusCode' => 404, |
| 372 | 'mimeType' => 'text/plain', |
| 373 | 'content' => $e->getMessage(), |
| 374 | ]; |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | } |