Add css variable for menubar breakpoint
[civicrm-core.git] / Civi / Core / AssetBuilder.php
CommitLineData
87e3fe24
TO
1<?php
2
3namespace Civi\Core;
4
5use 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 * @code
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 * @endCode
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 * @code
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 * @endCode
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 */
73class AssetBuilder {
74
e7b8261d
TO
75 /**
76 * @return array
77 * Array(string $value => string $label).
78 */
79 public static function getCacheModes() {
c64f69d9 80 return [
e7b8261d
TO
81 '0' => ts('Disable'),
82 '1' => ts('Enable'),
83 'auto' => ts('Auto'),
c64f69d9 84 ];
e7b8261d
TO
85 }
86
34f3bbd9
SL
87 /**
88 * @var mixed
89 */
87e3fe24
TO
90 protected $cacheEnabled;
91
92 /**
93 * AssetBuilder constructor.
94 * @param $cacheEnabled
95 */
96 public function __construct($cacheEnabled = NULL) {
97 if ($cacheEnabled === NULL) {
e7b8261d
TO
98 $cacheEnabled = \Civi::settings()->get('assetCache');
99 if ($cacheEnabled === 'auto') {
100 $cacheEnabled = !\CRM_Core_Config::singleton()->debug;
101 }
102 $cacheEnabled = (bool) $cacheEnabled;
87e3fe24
TO
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 */
c64f69d9 127 public function getUrl($name, $params = []) {
87e3fe24
TO
128 if (!$this->isValidName($name)) {
129 throw new \RuntimeException("Invalid dynamic asset name");
130 }
131
132 if ($this->isCacheEnabled()) {
133 $fileName = $this->build($name, $params);
134 return $this->getCacheUrl($fileName);
135 }
136 else {
c64f69d9 137 return \CRM_Utils_System::url('civicrm/asset/builder', [
87e3fe24
TO
138 'an' => $name,
139 'ap' => $this->encode($params),
140 'ad' => $this->digest($name, $params),
c64f69d9 141 ], TRUE, NULL, FALSE);
87e3fe24
TO
142 }
143 }
144
e5c376e7
TO
145 /**
146 * @param string $name
147 * Ex: 'angular.json'.
148 * @param array $params
149 * @return string
150 * URL.
151 * Ex: '/var/www/files/civicrm/dyn/angular.abcd1234abcd1234.json'.
152 */
c64f69d9 153 public function getPath($name, $params = []) {
e5c376e7
TO
154 if (!$this->isValidName($name)) {
155 throw new \RuntimeException("Invalid dynamic asset name");
156 }
157
158 $fileName = $this->build($name, $params);
159 return $this->getCachePath($fileName);
160 }
161
87e3fe24
TO
162 /**
163 * Build the cached copy of an $asset.
164 *
165 * @param string $name
166 * Ex: 'angular.json'.
167 * @param array $params
168 * @param bool $force
169 * Build the asset anew, even if it already exists.
170 * @return string
171 * File name (relative to cache folder).
172 * Ex: 'angular.abcd1234abcd1234.json'.
173 * @throws UnknownAssetException
174 */
175 public function build($name, $params, $force = FALSE) {
176 if (!$this->isValidName($name)) {
177 throw new UnknownAssetException("Asset name is malformed");
178 }
179 $nameParts = explode('.', $name);
c64f69d9 180 array_splice($nameParts, -1, 0, [$this->digest($name, $params)]);
87e3fe24
TO
181 $fileName = implode('.', $nameParts);
182 if ($force || !file_exists($this->getCachePath($fileName))) {
183 // No file locking, but concurrent writers should produce
184 // the same data, so we'll just plow ahead.
185
186 if (!file_exists($this->getCachePath())) {
187 mkdir($this->getCachePath());
188 }
189
190 $rendered = $this->render($name, $params);
191 file_put_contents($this->getCachePath($fileName), $rendered['content']);
192 return $fileName;
193 }
194 return $fileName;
195 }
196
197 /**
198 * Generate the content for a dynamic asset.
199 *
200 * @param string $name
201 * @param array $params
202 * @return array
203 * Array with keys:
204 * - statusCode: int, ex: 200.
205 * - mimeType: string, ex: 'text/html'.
206 * - content: string, ex: '<body>Hello world</body>'.
207 * @throws \CRM_Core_Exception
208 */
c64f69d9 209 public function render($name, $params = []) {
87e3fe24
TO
210 if (!$this->isValidName($name)) {
211 throw new UnknownAssetException("Asset name is malformed");
212 }
213 \CRM_Utils_Hook::buildAsset($name, $params, $mimeType, $content);
214 if ($mimeType === NULL && $content === NULL) {
215 throw new UnknownAssetException("Unrecognized asset name: $name");
216 }
217 // Beg your pardon, sir. Please may I have an HTTP response class instead?
c64f69d9 218 return [
87e3fe24
TO
219 'statusCode' => 200,
220 'mimeType' => $mimeType,
221 'content' => $content,
c64f69d9 222 ];
87e3fe24
TO
223 }
224
225 /**
226 * Clear out any cache files.
227 */
228 public function clear() {
229 \CRM_Utils_File::cleanDir($this->getCachePath());
230 }
231
232 /**
233 * Determine the local path of a cache file.
234 *
235 * @param string|NULL $fileName
236 * Ex: 'angular.abcd1234abcd1234.json'.
237 * @return string
238 * URL.
239 * Ex: '/var/www/files/civicrm/dyn/angular.abcd1234abcd1234.json'.
240 */
241 protected function getCachePath($fileName = NULL) {
242 // imageUploadDir has the correct functional properties but a wonky name.
243 $suffix = ($fileName === NULL) ? '' : (DIRECTORY_SEPARATOR . $fileName);
34f3bbd9 244 return \CRM_Utils_File::addTrailingSlash(\CRM_Core_Config::singleton()->imageUploadDir)
87e3fe24
TO
245 . 'dyn' . $suffix;
246 }
247
248 /**
249 * Determine the URL of a cache file.
250 *
251 * @param string|NULL $fileName
252 * Ex: 'angular.abcd1234abcd1234.json'.
253 * @return string
254 * URL.
255 * Ex: 'http://example.org/files/civicrm/dyn/angular.abcd1234abcd1234.json'.
256 */
257 protected function getCacheUrl($fileName = NULL) {
258 // imageUploadURL has the correct functional properties but a wonky name.
259 $suffix = ($fileName === NULL) ? '' : ('/' . $fileName);
34f3bbd9 260 return \CRM_Utils_File::addTrailingSlash(\CRM_Core_Config::singleton()->imageUploadURL, '/')
87e3fe24
TO
261 . 'dyn' . $suffix;
262 }
263
264 /**
265 * Create a unique identifier for the $params.
266 *
267 * This identifier is designed to avoid accidental cache collisions.
268 *
269 * @param string $name
270 * @param array $params
271 * @return string
272 */
273 protected function digest($name, $params) {
274 // WISHLIST: For secure digest, generate+persist privatekey & call hash_hmac.
275 ksort($params);
276 $digest = md5(
277 $name .
278 \CRM_Core_Resources::singleton()->getCacheCode() .
279 \CRM_Core_Config_Runtime::getId() .
280 json_encode($params)
281 );
282 return $digest;
283 }
284
285 /**
286 * Encode $params in a format that's optimized for shorter URLs.
287 *
288 * @param array $params
289 * @return string
290 */
291 protected function encode($params) {
292 if (empty($params)) {
293 return '';
294 }
295
296 $str = json_encode($params);
297 if (function_exists('gzdeflate')) {
298 $str = gzdeflate($str);
299 }
300 return base64_encode($str);
301 }
302
303 /**
304 * @param string $str
305 * @return array
306 */
307 protected function decode($str) {
308 if ($str === NULL || $str === FALSE || $str === '') {
c64f69d9 309 return [];
87e3fe24
TO
310 }
311
312 $str = base64_decode($str);
313 if (function_exists('gzdeflate')) {
314 $str = gzinflate($str);
315 }
316 return json_decode($str, TRUE);
317 }
318
319 /**
320 * @return bool
321 */
322 public function isCacheEnabled() {
323 return $this->cacheEnabled;
324 }
325
326 /**
327 * @param bool|null $cacheEnabled
328 * @return AssetBuilder
329 */
330 public function setCacheEnabled($cacheEnabled) {
331 $this->cacheEnabled = $cacheEnabled;
332 return $this;
333 }
334
335 /**
336 * (INTERNAL ONLY)
337 *
338 * Execute a page-request for `civicrm/asset/builder`.
339 */
340 public static function pageRun() {
341 // Beg your pardon, sir. Please may I have an HTTP response class instead?
342 $asset = self::pageRender($_GET);
343 if (function_exists('http_response_code')) {
344 // PHP 5.4+
345 http_response_code($asset['statusCode']);
346 }
347 else {
348 header('X-PHP-Response-Code: ' . $asset['statusCode'], TRUE, $asset['statusCode']);
349 }
350
351 header('Content-Type: ' . $asset['mimeType']);
352 echo $asset['content'];
353 \CRM_Utils_System::civiExit();
354 }
355
356 /**
357 * (INTERNAL ONLY)
358 *
359 * Execute a page-request for `civicrm/asset/builder`.
360 *
361 * @param array $get
362 * The _GET values.
363 * @return array
364 * Array with keys:
365 * - statusCode: int, ex 200.
366 * - mimeType: string, ex 'text/html'.
367 * - content: string, ex '<body>Hello world</body>'.
368 */
369 public static function pageRender($get) {
370 // Beg your pardon, sir. Please may I have an HTTP response class instead?
371 try {
372 $assets = \Civi::service('asset_builder');
373 return $assets->render($get['an'], $assets->decode($get['ap']));
374 }
375 catch (UnknownAssetException $e) {
c64f69d9 376 return [
87e3fe24
TO
377 'statusCode' => 404,
378 'mimeType' => 'text/plain',
379 'content' => $e->getMessage(),
c64f69d9 380 ];
87e3fe24
TO
381 }
382 }
383
384}