Commit | Line | Data |
---|---|---|
87e3fe24 TO |
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 | * | |
0b882a86 | 23 | * ``` |
87e3fe24 TO |
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 | * } | |
0b882a86 | 40 | * ``` |
87e3fe24 TO |
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 | * | |
0b882a86 | 48 | * ``` |
87e3fe24 TO |
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 | * } | |
0b882a86 | 66 | * ``` |
87e3fe24 TO |
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 | ||
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 = []) { |
c4560ed2 CW |
128 | \CRM_Utils_Hook::getAssetUrl($name, $params); |
129 | ||
87e3fe24 TO |
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 { | |
c64f69d9 | 139 | return \CRM_Utils_System::url('civicrm/asset/builder', [ |
87e3fe24 TO |
140 | 'an' => $name, |
141 | 'ap' => $this->encode($params), | |
142 | 'ad' => $this->digest($name, $params), | |
c64f69d9 | 143 | ], TRUE, NULL, FALSE); |
87e3fe24 TO |
144 | } |
145 | } | |
146 | ||
e5c376e7 TO |
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 | */ | |
c64f69d9 | 155 | public function getPath($name, $params = []) { |
e5c376e7 TO |
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 | ||
87e3fe24 TO |
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); | |
c64f69d9 | 182 | array_splice($nameParts, -1, 0, [$this->digest($name, $params)]); |
87e3fe24 TO |
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 | } | |
be615bf5 AD |
191 | try { |
192 | $rendered = $this->render($name, $params); | |
193 | file_put_contents($this->getCachePath($fileName), $rendered['content']); | |
194 | return $fileName; | |
195 | } | |
196 | catch (UnknownAssetException $e) { | |
f555d59e | 197 | // unexpected error, log and continue |
cb0c7c09 | 198 | \Civi::log()->error('Unexpected error while rendering a file in the AssetBuilder: ' . $e->getMessage(), ['exception' => $e]); |
be615bf5 | 199 | } |
87e3fe24 TO |
200 | } |
201 | return $fileName; | |
202 | } | |
203 | ||
204 | /** | |
205 | * Generate the content for a dynamic asset. | |
206 | * | |
207 | * @param string $name | |
208 | * @param array $params | |
209 | * @return array | |
210 | * Array with keys: | |
211 | * - statusCode: int, ex: 200. | |
212 | * - mimeType: string, ex: 'text/html'. | |
213 | * - content: string, ex: '<body>Hello world</body>'. | |
214 | * @throws \CRM_Core_Exception | |
215 | */ | |
c64f69d9 | 216 | public function render($name, $params = []) { |
87e3fe24 TO |
217 | if (!$this->isValidName($name)) { |
218 | throw new UnknownAssetException("Asset name is malformed"); | |
219 | } | |
220 | \CRM_Utils_Hook::buildAsset($name, $params, $mimeType, $content); | |
221 | if ($mimeType === NULL && $content === NULL) { | |
222 | throw new UnknownAssetException("Unrecognized asset name: $name"); | |
223 | } | |
224 | // Beg your pardon, sir. Please may I have an HTTP response class instead? | |
c64f69d9 | 225 | return [ |
87e3fe24 TO |
226 | 'statusCode' => 200, |
227 | 'mimeType' => $mimeType, | |
228 | 'content' => $content, | |
c64f69d9 | 229 | ]; |
87e3fe24 TO |
230 | } |
231 | ||
232 | /** | |
233 | * Clear out any cache files. | |
18f5b231 | 234 | * |
235 | * @param bool $removeDir Should folder itself be removed too. | |
87e3fe24 | 236 | */ |
18f5b231 | 237 | public function clear($removeDir = TRUE) { |
238 | \CRM_Utils_File::cleanDir($this->getCachePath(), $removeDir); | |
87e3fe24 TO |
239 | } |
240 | ||
241 | /** | |
242 | * Determine the local path of a cache file. | |
243 | * | |
244 | * @param string|NULL $fileName | |
245 | * Ex: 'angular.abcd1234abcd1234.json'. | |
246 | * @return string | |
247 | * URL. | |
248 | * Ex: '/var/www/files/civicrm/dyn/angular.abcd1234abcd1234.json'. | |
249 | */ | |
250 | protected function getCachePath($fileName = NULL) { | |
251 | // imageUploadDir has the correct functional properties but a wonky name. | |
252 | $suffix = ($fileName === NULL) ? '' : (DIRECTORY_SEPARATOR . $fileName); | |
34f3bbd9 | 253 | return \CRM_Utils_File::addTrailingSlash(\CRM_Core_Config::singleton()->imageUploadDir) |
87e3fe24 TO |
254 | . 'dyn' . $suffix; |
255 | } | |
256 | ||
257 | /** | |
258 | * Determine the URL of a cache file. | |
259 | * | |
260 | * @param string|NULL $fileName | |
261 | * Ex: 'angular.abcd1234abcd1234.json'. | |
262 | * @return string | |
263 | * URL. | |
264 | * Ex: 'http://example.org/files/civicrm/dyn/angular.abcd1234abcd1234.json'. | |
265 | */ | |
266 | protected function getCacheUrl($fileName = NULL) { | |
267 | // imageUploadURL has the correct functional properties but a wonky name. | |
268 | $suffix = ($fileName === NULL) ? '' : ('/' . $fileName); | |
34f3bbd9 | 269 | return \CRM_Utils_File::addTrailingSlash(\CRM_Core_Config::singleton()->imageUploadURL, '/') |
87e3fe24 TO |
270 | . 'dyn' . $suffix; |
271 | } | |
272 | ||
273 | /** | |
274 | * Create a unique identifier for the $params. | |
275 | * | |
276 | * This identifier is designed to avoid accidental cache collisions. | |
277 | * | |
278 | * @param string $name | |
279 | * @param array $params | |
280 | * @return string | |
281 | */ | |
282 | protected function digest($name, $params) { | |
283 | // WISHLIST: For secure digest, generate+persist privatekey & call hash_hmac. | |
284 | ksort($params); | |
285 | $digest = md5( | |
286 | $name . | |
287 | \CRM_Core_Resources::singleton()->getCacheCode() . | |
288 | \CRM_Core_Config_Runtime::getId() . | |
289 | json_encode($params) | |
290 | ); | |
291 | return $digest; | |
292 | } | |
293 | ||
294 | /** | |
295 | * Encode $params in a format that's optimized for shorter URLs. | |
296 | * | |
297 | * @param array $params | |
298 | * @return string | |
299 | */ | |
300 | protected function encode($params) { | |
301 | if (empty($params)) { | |
302 | return ''; | |
303 | } | |
304 | ||
305 | $str = json_encode($params); | |
306 | if (function_exists('gzdeflate')) { | |
307 | $str = gzdeflate($str); | |
308 | } | |
309 | return base64_encode($str); | |
310 | } | |
311 | ||
312 | /** | |
313 | * @param string $str | |
314 | * @return array | |
315 | */ | |
316 | protected function decode($str) { | |
317 | if ($str === NULL || $str === FALSE || $str === '') { | |
c64f69d9 | 318 | return []; |
87e3fe24 TO |
319 | } |
320 | ||
321 | $str = base64_decode($str); | |
322 | if (function_exists('gzdeflate')) { | |
323 | $str = gzinflate($str); | |
324 | } | |
325 | return json_decode($str, TRUE); | |
326 | } | |
327 | ||
328 | /** | |
329 | * @return bool | |
330 | */ | |
331 | public function isCacheEnabled() { | |
332 | return $this->cacheEnabled; | |
333 | } | |
334 | ||
335 | /** | |
336 | * @param bool|null $cacheEnabled | |
337 | * @return AssetBuilder | |
338 | */ | |
339 | public function setCacheEnabled($cacheEnabled) { | |
340 | $this->cacheEnabled = $cacheEnabled; | |
341 | return $this; | |
342 | } | |
343 | ||
344 | /** | |
345 | * (INTERNAL ONLY) | |
346 | * | |
347 | * Execute a page-request for `civicrm/asset/builder`. | |
348 | */ | |
349 | public static function pageRun() { | |
350 | // Beg your pardon, sir. Please may I have an HTTP response class instead? | |
351 | $asset = self::pageRender($_GET); | |
46dddc5c | 352 | \CRM_Utils_System::sendResponse(new \GuzzleHttp\Psr7\Response($asset['statusCode'], ['Content-Type' => $asset['mimeType']], $asset['content'])); |
87e3fe24 TO |
353 | } |
354 | ||
355 | /** | |
356 | * (INTERNAL ONLY) | |
357 | * | |
358 | * Execute a page-request for `civicrm/asset/builder`. | |
359 | * | |
360 | * @param array $get | |
361 | * The _GET values. | |
362 | * @return array | |
363 | * Array with keys: | |
364 | * - statusCode: int, ex 200. | |
365 | * - mimeType: string, ex 'text/html'. | |
366 | * - content: string, ex '<body>Hello world</body>'. | |
367 | */ | |
368 | public static function pageRender($get) { | |
369 | // Beg your pardon, sir. Please may I have an HTTP response class instead? | |
370 | try { | |
371 | $assets = \Civi::service('asset_builder'); | |
372 | return $assets->render($get['an'], $assets->decode($get['ap'])); | |
373 | } | |
374 | catch (UnknownAssetException $e) { | |
c64f69d9 | 375 | return [ |
87e3fe24 TO |
376 | 'statusCode' => 404, |
377 | 'mimeType' => 'text/plain', | |
378 | 'content' => $e->getMessage(), | |
c64f69d9 | 379 | ]; |
87e3fe24 TO |
380 | } |
381 | } | |
382 | ||
383 | } |