5 use Civi\Core\Exception\UnknownAssetException
;
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.)
16 * There are generally two aspects to usage -- creating a URL
17 * for the asset, and defining the content of the asset.
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.
24 * // Build a URL to `api-fields.json`.
25 * $url = \Civi::service('asset_builder')->getUrl('api-fields.json');
27 * // Define the content of `api-fields.json`.
28 * function hook_civicrm_buildAsset($asset, $params, &$mimeType, &$content) {
29 * if ($asset !== 'api-fields.json') return;
31 * $entities = civicrm_api3('Entity', 'get', array());
33 * foreach ($entities['values'] as $entity) {
34 * $fields[$entity] = civicrm_api3($entity, 'getfields');
37 * $mimeType = 'application/json';
38 * $content = json_encode($fields);
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:
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'),
54 * // Define the content of `api-fields.json`.
55 * function hook_civicrm_buildAsset($asset, $params, &$mimeType, &$content) {
56 * if ($asset !== 'api-fields.json') return;
59 * foreach ($params['entities'] as $entity) {
60 * $fields[$entity] = civicrm_api3($entity, 'getfields');
63 * $mimeType = 'application/json';
64 * $content = json_encode($fields);
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.
77 * Array(string $value => string $label).
79 public static function getCacheModes() {
90 protected $cacheEnabled;
93 * AssetBuilder constructor.
94 * @param $cacheEnabled
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
;
102 $cacheEnabled = (bool) $cacheEnabled;
104 $this->cacheEnabled
= $cacheEnabled;
108 * Determine if $name is a well-formed asset name.
110 * @param string $name
113 public function isValidName($name) {
114 return preg_match(';^[a-zA-Z0-9\.\-_/]+$;', $name)
115 && strpos($name, '..') === FALSE
116 && strpos($name, '.') !== FALSE;
120 * @param string $name
121 * Ex: 'angular.json'.
122 * @param array $params
125 * Ex: 'http://example.org/files/civicrm/dyn/angular.abcd1234abcd1234.json'.
127 public function getUrl($name, $params = []) {
128 \CRM_Utils_Hook
::getAssetUrl($name, $params);
130 if (!$this->isValidName($name)) {
131 throw new \
RuntimeException("Invalid dynamic asset name");
134 if ($this->isCacheEnabled()) {
135 $fileName = $this->build($name, $params);
136 return $this->getCacheUrl($fileName);
139 return \CRM_Utils_System
::url('civicrm/asset/builder', [
141 'ap' => $this->encode($params),
142 'ad' => $this->digest($name, $params),
143 ], TRUE, NULL, FALSE);
148 * @param string $name
149 * Ex: 'angular.json'.
150 * @param array $params
153 * Ex: '/var/www/files/civicrm/dyn/angular.abcd1234abcd1234.json'.
155 public function getPath($name, $params = []) {
156 if (!$this->isValidName($name)) {
157 throw new \
RuntimeException("Invalid dynamic asset name");
160 $fileName = $this->build($name, $params);
161 return $this->getCachePath($fileName);
165 * Build the cached copy of an $asset.
167 * @param string $name
168 * Ex: 'angular.json'.
169 * @param array $params
171 * Build the asset anew, even if it already exists.
173 * File name (relative to cache folder).
174 * Ex: 'angular.abcd1234abcd1234.json'.
175 * @throws UnknownAssetException
177 public function build($name, $params, $force = FALSE) {
178 if (!$this->isValidName($name)) {
179 throw new UnknownAssetException("Asset name is malformed");
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.
188 if (!file_exists($this->getCachePath())) {
189 mkdir($this->getCachePath());
192 $rendered = $this->render($name, $params);
193 file_put_contents($this->getCachePath($fileName), $rendered['content']);
196 catch (UnknownAssetException
$e) {
197 // unexpected error, log and continue
198 Civi
::log()->error('Unexpected error while rendering a file in the AssetBuilder');
205 * Generate the content for a dynamic asset.
207 * @param string $name
208 * @param array $params
211 * - statusCode: int, ex: 200.
212 * - mimeType: string, ex: 'text/html'.
213 * - content: string, ex: '<body>Hello world</body>'.
214 * @throws \CRM_Core_Exception
216 public function render($name, $params = []) {
217 if (!$this->isValidName($name)) {
218 throw new UnknownAssetException("Asset name is malformed");
220 \CRM_Utils_Hook
::buildAsset($name, $params, $mimeType, $content);
221 if ($mimeType === NULL && $content === NULL) {
222 throw new UnknownAssetException("Unrecognized asset name: $name");
224 // Beg your pardon, sir. Please may I have an HTTP response class instead?
227 'mimeType' => $mimeType,
228 'content' => $content,
233 * Clear out any cache files.
235 * @param bool $removeDir Should folder itself be removed too.
237 public function clear($removeDir = TRUE) {
238 \CRM_Utils_File
::cleanDir($this->getCachePath(), $removeDir);
242 * Determine the local path of a cache file.
244 * @param string|NULL $fileName
245 * Ex: 'angular.abcd1234abcd1234.json'.
248 * Ex: '/var/www/files/civicrm/dyn/angular.abcd1234abcd1234.json'.
250 protected function getCachePath($fileName = NULL) {
251 // imageUploadDir has the correct functional properties but a wonky name.
252 $suffix = ($fileName === NULL) ?
'' : (DIRECTORY_SEPARATOR
. $fileName);
253 return \CRM_Utils_File
::addTrailingSlash(\CRM_Core_Config
::singleton()->imageUploadDir
)
258 * Determine the URL of a cache file.
260 * @param string|NULL $fileName
261 * Ex: 'angular.abcd1234abcd1234.json'.
264 * Ex: 'http://example.org/files/civicrm/dyn/angular.abcd1234abcd1234.json'.
266 protected function getCacheUrl($fileName = NULL) {
267 // imageUploadURL has the correct functional properties but a wonky name.
268 $suffix = ($fileName === NULL) ?
'' : ('/' . $fileName);
269 return \CRM_Utils_File
::addTrailingSlash(\CRM_Core_Config
::singleton()->imageUploadURL
, '/')
274 * Create a unique identifier for the $params.
276 * This identifier is designed to avoid accidental cache collisions.
278 * @param string $name
279 * @param array $params
282 protected function digest($name, $params) {
283 // WISHLIST: For secure digest, generate+persist privatekey & call hash_hmac.
287 \CRM_Core_Resources
::singleton()->getCacheCode() .
288 \CRM_Core_Config_Runtime
::getId() .
295 * Encode $params in a format that's optimized for shorter URLs.
297 * @param array $params
300 protected function encode($params) {
301 if (empty($params)) {
305 $str = json_encode($params);
306 if (function_exists('gzdeflate')) {
307 $str = gzdeflate($str);
309 return base64_encode($str);
316 protected function decode($str) {
317 if ($str === NULL ||
$str === FALSE ||
$str === '') {
321 $str = base64_decode($str);
322 if (function_exists('gzdeflate')) {
323 $str = gzinflate($str);
325 return json_decode($str, TRUE);
331 public function isCacheEnabled() {
332 return $this->cacheEnabled
;
336 * @param bool|null $cacheEnabled
337 * @return AssetBuilder
339 public function setCacheEnabled($cacheEnabled) {
340 $this->cacheEnabled
= $cacheEnabled;
347 * Execute a page-request for `civicrm/asset/builder`.
349 public static function pageRun() {
350 // Beg your pardon, sir. Please may I have an HTTP response class instead?
351 $asset = self
::pageRender($_GET);
352 \CRM_Utils_System
::sendResponse(new \GuzzleHttp\Psr7\
Response($asset['statusCode'], ['Content-Type' => $asset['mimeType']], $asset['content']));
358 * Execute a page-request for `civicrm/asset/builder`.
364 * - statusCode: int, ex 200.
365 * - mimeType: string, ex 'text/html'.
366 * - content: string, ex '<body>Hello world</body>'.
368 public static function pageRender($get) {
369 // Beg your pardon, sir. Please may I have an HTTP response class instead?
371 $assets = \Civi
::service('asset_builder');
372 return $assets->render($get['an'], $assets->decode($get['ap']));
374 catch (UnknownAssetException
$e) {
377 'mimeType' => 'text/plain',
378 'content' => $e->getMessage(),