Merge pull request #22195 from eileenmcnaughton/smarty20
[civicrm-core.git] / CRM / Extension / Mapper.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
10 */
11
12 /**
13 * This class proivdes various helper functions for locating extensions
14 * data. It's designed for compatibility with pre-existing functions from
15 * CRM_Core_Extensions.
16 *
17 * Most of these helper functions originate with the first major iteration
18 * of extensions -- a time when every extension had one eponymous PHP class,
19 * when there was no PHP class-loader, and when there was special-case logic
20 * sprinkled around to handle loading of "extension classes".
21 *
22 * With module-extensions (Civi 4.2+), there are no eponymous classes --
23 * instead, module-extensions follow the same class-naming and class-loading
24 * practices as core (and don't require special-case logic for class
25 * loading). Consequently, the helpers in here aren't much used with
26 * module-extensions.
27 *
28 * @package CRM
29 * @copyright CiviCRM LLC https://civicrm.org/licensing
30 */
31 class CRM_Extension_Mapper {
32
33 /**
34 * An URL for public extensions repository.
35 */
36
37 /**
38 * Extension info file name.
39 */
40 const EXT_TEMPLATES_DIRNAME = 'templates';
41
42 /**
43 * @var CRM_Extension_Container_Interface
44 */
45 protected $container;
46
47 /**
48 * @var \CRM_Extension_Info[]
49 * (key => CRM_Extension_Info)
50 */
51 protected $infos = [];
52
53 /**
54 * @var array
55 */
56 protected $moduleExtensions = NULL;
57
58 /**
59 * @var CRM_Utils_Cache_Interface
60 */
61 protected $cache;
62
63 protected $cacheKey;
64
65 protected $civicrmPath;
66
67 protected $civicrmUrl;
68
69 /**
70 * @var array
71 * Array(string $extKey => CRM_Extension_Upgrader_Interface $upgrader)
72 */
73 protected $upgraders = [];
74
75 /**
76 * @param CRM_Extension_Container_Interface $container
77 * @param CRM_Utils_Cache_Interface $cache
78 * @param null $cacheKey
79 * @param null $civicrmPath
80 * @param null $civicrmUrl
81 */
82 public function __construct(CRM_Extension_Container_Interface $container, CRM_Utils_Cache_Interface $cache = NULL, $cacheKey = NULL, $civicrmPath = NULL, $civicrmUrl = NULL) {
83 $this->container = $container;
84 $this->cache = $cache;
85 $this->cacheKey = $cacheKey;
86 if ($civicrmUrl) {
87 $this->civicrmUrl = rtrim($civicrmUrl, '/');
88 }
89 else {
90 $config = CRM_Core_Config::singleton();
91 $this->civicrmUrl = rtrim($config->resourceBase, '/');
92 }
93 if ($civicrmPath) {
94 $this->civicrmPath = rtrim($civicrmPath, '/');
95 }
96 else {
97 global $civicrm_root;
98 $this->civicrmPath = rtrim($civicrm_root, '/');
99 }
100 }
101
102 /**
103 * Given the class, provides extension's key.
104 *
105 *
106 * @param string $clazz
107 * Extension class name.
108 *
109 * @return string
110 * name of extension key
111 */
112 public function classToKey($clazz) {
113 return str_replace('_', '.', $clazz);
114 }
115
116 /**
117 * Given the class, provides extension path.
118 *
119 *
120 * @param $clazz
121 *
122 * @return string
123 * full path the extension .php file
124 */
125 public function classToPath($clazz) {
126 $elements = explode('_', $clazz);
127 $key = implode('.', $elements);
128 return $this->keyToPath($key);
129 }
130
131 /**
132 * Given the string, returns true or false if it's an extension key.
133 *
134 *
135 * @param string $key
136 * A string which might be an extension key.
137 *
138 * @return bool
139 * true if given string is an extension name
140 */
141 public function isExtensionKey($key) {
142 // check if the string is an extension name or the class
143 return (strpos($key, '.') !== FALSE) ? TRUE : FALSE;
144 }
145
146 /**
147 * Given the string, returns true or false if it's an extension class name.
148 *
149 *
150 * @param string $clazz
151 * A string which might be an extension class name.
152 *
153 * @return bool
154 * true if given string is an extension class name
155 */
156 public function isExtensionClass($clazz) {
157
158 if (substr($clazz, 0, 4) != 'CRM_') {
159 return (bool) preg_match('/^[a-z0-9]+(_[a-z0-9]+)+$/', $clazz);
160 }
161 return FALSE;
162 }
163
164 /**
165 * @param string $key
166 * Extension fully-qualified-name.
167 * @param bool $fresh
168 *
169 * @throws CRM_Extension_Exception
170 *
171 * @return CRM_Extension_Info
172 */
173 public function keyToInfo($key, $fresh = FALSE) {
174 if ($fresh || !array_key_exists($key, $this->infos)) {
175 try {
176 $this->infos[$key] = CRM_Extension_Info::loadFromFile($this->container->getPath($key) . DIRECTORY_SEPARATOR . CRM_Extension_Info::FILENAME);
177 }
178 catch (CRM_Extension_Exception $e) {
179 // file has more detailed info, but we'll fallback to DB if it's missing -- DB has enough info to uninstall
180 $dbInfo = CRM_Extension_System::singleton()->getManager()->createInfoFromDB($key);
181 if (!$dbInfo) {
182 throw $e;
183 }
184 $this->infos[$key] = $dbInfo;
185 }
186 }
187 return $this->infos[$key];
188 }
189
190 /**
191 * Given the key, provides extension's class name.
192 *
193 *
194 * @param string $key
195 * Extension key.
196 *
197 * @return string
198 * name of extension's main class
199 */
200 public function keyToClass($key) {
201 return str_replace('.', '_', $key);
202 }
203
204 /**
205 * Given the key, provides the path to file containing
206 * extension's main class.
207 *
208 *
209 * @param string $key
210 * Extension key.
211 *
212 * @return string
213 * path to file containing extension's main class
214 */
215 public function keyToPath($key) {
216 $info = $this->keyToInfo($key);
217 return $this->container->getPath($key) . DIRECTORY_SEPARATOR . $info->file . '.php';
218 }
219
220 /**
221 * Given the key, provides the path to file containing
222 * extension's main class.
223 *
224 * @param string $key
225 * Extension key.
226 * @return string
227 * local path of the extension source tree
228 */
229 public function keyToBasePath($key) {
230 if ($key == 'civicrm') {
231 return $this->civicrmPath;
232 }
233 return $this->container->getPath($key);
234 }
235
236 /**
237 * Given the key, provides the path to file containing
238 * extension's main class.
239 *
240 *
241 * @param string $key
242 * Extension key.
243 *
244 * @return string
245 * url for resources in this extension
246 *
247 * @throws \CRM_Extension_Exception_MissingException
248 */
249 public function keyToUrl($key) {
250 if ($key === 'civicrm') {
251 // CRM-12130 Workaround: If the domain's config_backend is NULL at the start of the request,
252 // then the Mapper is wrongly constructed with an empty value for $this->civicrmUrl.
253 if (empty($this->civicrmUrl)) {
254 $config = CRM_Core_Config::singleton();
255 return rtrim($config->resourceBase, '/');
256 }
257 return $this->civicrmUrl;
258 }
259
260 return $this->container->getResUrl($key);
261 }
262
263 /**
264 * Fetch the list of active extensions of type 'module'
265 *
266 * @param bool $fresh
267 * whether to forcibly reload extensions list from canonical store.
268 * @return array
269 * array(array('prefix' => $, 'fullName' => $, 'filePath' => $))
270 */
271 public function getActiveModuleFiles($fresh = FALSE) {
272 if (!defined('CIVICRM_DSN')) {
273 // hmm, ok
274 return [];
275 }
276
277 // The list of module files is cached in two tiers. The tiers are slightly
278 // different:
279 //
280 // 1. The persistent tier (cache) stores
281 // names WITHOUT absolute paths.
282 // 2. The ephemeral/thread-local tier (statics) stores names
283 // WITH absolute paths.
284 // Return static value instead of re-running query
285 if (isset(Civi::$statics[__CLASS__]['moduleExtensions']) && !$fresh) {
286 return Civi::$statics[__CLASS__]['moduleExtensions'];
287 }
288
289 $moduleExtensions = NULL;
290
291 // Checked if it's stored in the persistent cache.
292 if ($this->cache && !$fresh) {
293 $moduleExtensions = $this->cache->get($this->cacheKey . '_moduleFiles');
294 }
295
296 // If cache is empty we build it from database.
297 if (!is_array($moduleExtensions)) {
298 $compat = CRM_Extension_System::getCompatibilityInfo();
299
300 // Check canonical module list
301 $moduleExtensions = [];
302 $sql = '
303 SELECT full_name, file
304 FROM civicrm_extension
305 WHERE is_active = 1
306 AND type = "module"
307 ';
308 $dao = CRM_Core_DAO::executeQuery($sql);
309 while ($dao->fetch()) {
310 if (!empty($compat[$dao->full_name]['force-uninstall'])) {
311 continue;
312 }
313 $moduleExtensions[] = [
314 'prefix' => $dao->file,
315 'fullName' => $dao->full_name,
316 'filePath' => NULL,
317 ];
318 }
319
320 if ($this->cache) {
321 $this->cache->set($this->cacheKey . '_moduleFiles', $moduleExtensions);
322 }
323 }
324
325 // Since we're not caching the full path we add it now.
326 array_walk($moduleExtensions, function(&$value, $key) {
327 try {
328 if (!$value['filePath']) {
329 $value['filePath'] = $this->keyToPath($value['fullName']);
330 }
331 }
332 catch (CRM_Extension_Exception $e) {
333 // Putting a stub here provides more consistency
334 // in how getActiveModuleFiles when racing between
335 // dirty file-removals and cache-clears.
336 CRM_Core_Session::setStatus($e->getMessage(), '', 'error');
337 $value['filePath'] = NULL;
338 }
339 });
340
341 Civi::$statics[__CLASS__]['moduleExtensions'] = $moduleExtensions;
342
343 return $moduleExtensions;
344 }
345
346 /**
347 * Get a list of base URLs for all active modules.
348 *
349 * @return array
350 * (string $extKey => string $baseUrl)
351 *
352 * @throws \CRM_Extension_Exception_MissingException
353 */
354 public function getActiveModuleUrls() {
355 // TODO optimization/caching
356 $urls = [];
357 $urls['civicrm'] = $this->keyToUrl('civicrm');
358 foreach ($this->getModules() as $module) {
359 /** @var $module CRM_Core_Module */
360 if ($module->is_active) {
361 try {
362 $urls[$module->name] = $this->keyToUrl($module->name);
363 }
364 catch (CRM_Extension_Exception_MissingException $e) {
365 CRM_Core_Session::setStatus(ts('An enabled extension is missing from the extensions directory') . ':' . $module->name);
366 }
367 }
368 }
369 return $urls;
370 }
371
372 /**
373 * Get a list of extension keys, filtered by the corresponding file path.
374 *
375 * @param string $pattern
376 * A file path. To search subdirectories, append "*".
377 * Ex: "/var/www/extensions/*"
378 * Ex: "/var/www/extensions/org.foo.bar"
379 * @return array
380 * Array(string $key).
381 * Ex: array("org.foo.bar").
382 */
383 public function getKeysByPath($pattern) {
384 $keys = [];
385
386 if (CRM_Utils_String::endsWith($pattern, '*')) {
387 $prefix = rtrim($pattern, '*');
388 foreach ($this->container->getKeys() as $key) {
389 $path = CRM_Utils_File::addTrailingSlash($this->container->getPath($key));
390 if (realpath($prefix) == realpath($path) || CRM_Utils_File::isChildPath($prefix, $path)) {
391 $keys[] = $key;
392 }
393 }
394 }
395 else {
396 foreach ($this->container->getKeys() as $key) {
397 $path = CRM_Utils_File::addTrailingSlash($this->container->getPath($key));
398 if (realpath($pattern) == realpath($path)) {
399 $keys[] = $key;
400 }
401 }
402 }
403
404 return $keys;
405 }
406
407 /**
408 * Get a list of extensions which match a given tag.
409 *
410 * @param string $tag
411 * Ex: 'foo'
412 * @return array
413 * Array(string $key).
414 * Ex: array("org.foo.bar").
415 */
416 public function getKeysByTag($tag) {
417 $allTags = $this->getAllTags();
418 return $allTags[$tag] ?? [];
419 }
420
421 /**
422 * Get a list of extension tags.
423 *
424 * @return array
425 * Ex: ['form-building' => ['org.civicrm.afform-gui', 'org.civicrm.afform-html']]
426 */
427 public function getAllTags() {
428 $tags = Civi::cache('short')->get('extension_tags', NULL);
429 if ($tags !== NULL) {
430 return $tags;
431 }
432
433 $tags = [];
434 $allInfos = $this->getAllInfos();
435 foreach ($allInfos as $key => $info) {
436 foreach ($info->tags as $tag) {
437 $tags[$tag][] = $key;
438 }
439 }
440 return $tags;
441 }
442
443 /**
444 * @return array
445 * Ex: $result['org.civicrm.foobar'] = new CRM_Extension_Info(...).
446 * @throws \CRM_Extension_Exception
447 * @throws \Exception
448 */
449 public function getAllInfos() {
450 foreach ($this->container->getKeys() as $key) {
451 try {
452 $this->keyToInfo($key);
453 }
454 catch (CRM_Extension_Exception_ParseException $e) {
455 CRM_Core_Session::setStatus(ts('Parse error in extension: %1', [
456 1 => $e->getMessage(),
457 ]), '', 'error');
458 CRM_Core_Error::debug_log_message("Parse error in extension: " . $e->getMessage());
459 continue;
460 }
461 }
462 return $this->infos;
463 }
464
465 /**
466 * @param string $name
467 *
468 * @return bool
469 */
470 public function isActiveModule($name) {
471 $activeModules = $this->getActiveModuleFiles();
472 foreach ($activeModules as $activeModule) {
473 if ($activeModule['prefix'] == $name) {
474 return TRUE;
475 }
476 }
477 return FALSE;
478 }
479
480 /**
481 * Get a list of all installed modules, including enabled and disabled ones
482 *
483 * @return CRM_Core_Module[]
484 */
485 public function getModules() {
486 $result = [];
487 $dao = new CRM_Core_DAO_Extension();
488 $dao->type = 'module';
489 $dao->find();
490 while ($dao->fetch()) {
491 $result[] = new CRM_Core_Module($dao->full_name, $dao->is_active);
492 }
493 return $result;
494 }
495
496 /**
497 * Given the class, provides the template path.
498 *
499 *
500 * @param string $clazz
501 * Extension class name.
502 *
503 * @return string
504 * path to extension's templates directory
505 */
506 public function getTemplatePath($clazz) {
507 $path = $this->container->getPath($this->classToKey($clazz));
508 return $path . DIRECTORY_SEPARATOR . self::EXT_TEMPLATES_DIRNAME;
509 /*
510 $path = $this->classToPath($clazz);
511 $pathElm = explode(DIRECTORY_SEPARATOR, $path);
512 array_pop($pathElm);
513 return implode(DIRECTORY_SEPARATOR, $pathElm) . DIRECTORY_SEPARATOR . self::EXT_TEMPLATES_DIRNAME;
514 */
515 }
516
517 /**
518 * Given te class, provides the template name.
519 * @todo consider multiple templates, support for one template for now
520 *
521 *
522 * @param string $clazz
523 * Extension class name.
524 *
525 * @return string
526 * extension's template name
527 */
528 public function getTemplateName($clazz) {
529 $info = $this->keyToInfo($this->classToKey($clazz));
530 return (string) $info->file . '.tpl';
531 }
532
533 public function refresh() {
534 $this->infos = [];
535 $this->moduleExtensions = NULL;
536 if ($this->cache) {
537 $this->cache->delete($this->cacheKey . '_moduleFiles');
538 }
539 // FIXME: How can code so code wrong be so right?
540 CRM_Extension_System::singleton()->getClassLoader()->refresh();
541 CRM_Extension_System::singleton()->getMixinLoader()->run(TRUE);
542 }
543
544 /**
545 * This returns a formatted string containing an extension upgrade link for the UI.
546 * @todo We should improve this to return more appropriate text. eg. when an extension is not installed
547 * it should not say "version xx is installed".
548 *
549 * @param array $remoteExtensionInfo
550 * @param array $localExtensionInfo
551 *
552 * @return string
553 */
554 public function getUpgradeLink($remoteExtensionInfo, $localExtensionInfo) {
555 if (!empty($remoteExtensionInfo) && version_compare($localExtensionInfo['version'], $remoteExtensionInfo->version, '<')) {
556 return ts('Version %1 is installed. <a %2>Upgrade to version %3</a>.', [
557 1 => $localExtensionInfo['version'],
558 2 => 'href="' . CRM_Utils_System::url('civicrm/admin/extensions', "action=update&id={$localExtensionInfo['key']}&key={$localExtensionInfo['key']}") . '"',
559 3 => $remoteExtensionInfo->version,
560 ]);
561 }
562 }
563
564 /**
565 * @param string $key
566 * Long name of the extension.
567 * Ex: 'org.example.myext'
568 *
569 * @return \CRM_Extension_Upgrader_Interface
570 */
571 public function getUpgrader(string $key) {
572 if (!array_key_exists($key, $this->upgraders)) {
573 $this->upgraders[$key] = NULL;
574
575 try {
576 $info = $this->keyToInfo($key);
577 }
578 catch (CRM_Extension_Exception_ParseException $e) {
579 CRM_Core_Session::setStatus(ts('Parse error in extension: %1', [
580 1 => $e->getMessage(),
581 ]), '', 'error');
582 CRM_Core_Error::debug_log_message("Parse error in extension: " . $e->getMessage());
583 return NULL;
584 }
585
586 if (!empty($info->upgrader)) {
587 $class = $info->upgrader;
588 $u = new $class();
589 $u->init(['key' => $key]);
590 $this->upgraders[$key] = $u;
591 }
592 }
593 return $this->upgraders[$key];
594 }
595
596 }