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