[REF] Update fetchAll function signature to match parent function
[civicrm-core.git] / CRM / Extension / Manager.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 * The extension manager handles installing, disabling enabling, and
14 * uninstalling extensions.
15 *
16 * @package CRM
17 * @copyright CiviCRM LLC https://civicrm.org/licensing
18 */
19 class CRM_Extension_Manager {
20 /**
21 * The extension is fully installed and enabled.
22 */
23 const STATUS_INSTALLED = 'installed';
24
25 /**
26 * The extension config has been applied to database but deactivated.
27 */
28 const STATUS_DISABLED = 'disabled';
29
30 /**
31 * The extension code is visible, but nothing has been applied to DB
32 */
33 const STATUS_UNINSTALLED = 'uninstalled';
34
35 /**
36 * The extension code is not locally accessible
37 */
38 const STATUS_UNKNOWN = 'unknown';
39
40 /**
41 * The extension is installed but the code is not accessible
42 */
43 const STATUS_INSTALLED_MISSING = 'installed-missing';
44
45 /**
46 * The extension was installed and is now disabled; the code is not accessible
47 */
48 const STATUS_DISABLED_MISSING = 'disabled-missing';
49
50 /**
51 * @var CRM_Extension_Container_Interface
52 *
53 * Note: Treat as private. This is only public to facilitate debugging.
54 */
55 public $fullContainer;
56
57 /**
58 * Default container.
59 *
60 * @var CRM_Extension_Container_Basic|false
61 *
62 * Note: Treat as private. This is only public to facilitate debugging.
63 */
64 public $defaultContainer;
65
66 /**
67 * Mapper.
68 *
69 * @var CRM_Extension_Mapper
70 *
71 * Note: Treat as private. This is only public to facilitate debugging.
72 */
73 public $mapper;
74
75 /**
76 * Type managers.
77 *
78 * @var array
79 *
80 * Format is (typeName => CRM_Extension_Manager_Interface)
81 *
82 * Note: Treat as private. This is only public to facilitate debugging.
83 */
84 public $typeManagers;
85
86 /**
87 * Statuses.
88 *
89 * @var array
90 *
91 * Format is (extensionKey => statusConstant)
92 *
93 * Note: Treat as private. This is only public to facilitate debugging.
94 */
95 public $statuses;
96
97 /**
98 * Class constructor.
99 *
100 * @param CRM_Extension_Container_Interface $fullContainer
101 * @param CRM_Extension_Container_Basic|FALSE $defaultContainer
102 * @param CRM_Extension_Mapper $mapper
103 * @param array $typeManagers
104 */
105 public function __construct(CRM_Extension_Container_Interface $fullContainer, $defaultContainer, CRM_Extension_Mapper $mapper, $typeManagers) {
106 $this->fullContainer = $fullContainer;
107 $this->defaultContainer = $defaultContainer;
108 $this->mapper = $mapper;
109 $this->typeManagers = $typeManagers;
110 }
111
112 /**
113 * Install or upgrade the code for an extension -- and perform any
114 * necessary database changes (eg replacing extension metadata).
115 *
116 * This only works if the extension is stored in the default container.
117 *
118 * @param string $tmpCodeDir
119 * Path to a local directory containing a copy of the new (inert) code.
120 * @throws CRM_Extension_Exception
121 */
122 public function replace($tmpCodeDir) {
123 if (!$this->defaultContainer) {
124 throw new CRM_Extension_Exception("Default extension container is not configured");
125 }
126
127 $newInfo = CRM_Extension_Info::loadFromFile($tmpCodeDir . DIRECTORY_SEPARATOR . CRM_Extension_Info::FILENAME);
128 $oldStatus = $this->getStatus($newInfo->key);
129
130 // find $tgtPath, $oldInfo, $typeManager
131 switch ($oldStatus) {
132 case self::STATUS_UNINSTALLED:
133 case self::STATUS_INSTALLED:
134 case self::STATUS_DISABLED:
135 // There is an old copy of the extension. Try to install in the same place -- but it must go somewhere in the default-container
136 // throws Exception
137 list ($oldInfo, $typeManager) = $this->_getInfoTypeHandler($newInfo->key);
138 $tgtPath = $this->fullContainer->getPath($newInfo->key);
139 if (!CRM_Utils_File::isChildPath($this->defaultContainer->getBaseDir(), $tgtPath)) {
140 // force installation in the default-container
141 $oldPath = $tgtPath;
142 $tgtPath = $this->defaultContainer->getBaseDir() . DIRECTORY_SEPARATOR . $newInfo->key;
143 CRM_Core_Session::setStatus(ts('A copy of the extension (%1) is in a system folder (%2). The system copy will be preserved, but the new copy will be used.', [
144 1 => $newInfo->key,
145 2 => $oldPath,
146 ]));
147 }
148 break;
149
150 case self::STATUS_INSTALLED_MISSING:
151 case self::STATUS_DISABLED_MISSING:
152 // the extension does not exist in any container; we're free to put it anywhere
153 $tgtPath = $this->defaultContainer->getBaseDir() . DIRECTORY_SEPARATOR . $newInfo->key;
154 // throws Exception
155 list ($oldInfo, $typeManager) = $this->_getMissingInfoTypeHandler($newInfo->key);
156 break;
157
158 case self::STATUS_UNKNOWN:
159 // the extension does not exist in any container; we're free to put it anywhere
160 $tgtPath = $this->defaultContainer->getBaseDir() . DIRECTORY_SEPARATOR . $newInfo->key;
161 $oldInfo = $typeManager = NULL;
162 break;
163
164 default:
165 throw new CRM_Extension_Exception("Cannot install or enable extension: {$newInfo->key}");
166 }
167
168 // move the code!
169 switch ($oldStatus) {
170 case self::STATUS_UNINSTALLED:
171 case self::STATUS_UNKNOWN:
172 // There are no DB records to worry about, so we'll just put the files in place
173 if (!CRM_Utils_File::replaceDir($tmpCodeDir, $tgtPath)) {
174 throw new CRM_Extension_Exception("Failed to move $tmpCodeDir to $tgtPath");
175 }
176 break;
177
178 case self::STATUS_INSTALLED:
179 case self::STATUS_INSTALLED_MISSING:
180 case self::STATUS_DISABLED:
181 case self::STATUS_DISABLED_MISSING:
182 // There are DB records; coordinate the file placement with the DB updates
183 $typeManager->onPreReplace($oldInfo, $newInfo);
184 if (!CRM_Utils_File::replaceDir($tmpCodeDir, $tgtPath)) {
185 throw new CRM_Extension_Exception("Failed to move $tmpCodeDir to $tgtPath");
186 }
187 $this->_updateExtensionEntry($newInfo);
188 $typeManager->onPostReplace($oldInfo, $newInfo);
189 break;
190
191 default:
192 throw new CRM_Extension_Exception("Cannot install or enable extension: {$newInfo->key}");
193 }
194
195 $this->refresh();
196 CRM_Core_Invoke::rebuildMenuAndCaches(TRUE);
197 }
198
199 /**
200 * Add records of the extension to the database -- and enable it
201 *
202 * @param string|array $keys
203 * One or more extension keys.
204 * @throws CRM_Extension_Exception
205 */
206 public function install($keys) {
207 $keys = (array) $keys;
208 $origStatuses = $this->getStatuses();
209
210 // TODO: to mitigate the risk of crashing during installation, scan
211 // keys/statuses/types before doing anything
212
213 // Check compatibility
214 $incompatible = [];
215 foreach ($keys as $key) {
216 if ($this->isIncompatible($key)) {
217 $incompatible[] = $key;
218 }
219 }
220 if ($incompatible) {
221 throw new CRM_Extension_Exception('Cannot install incompatible extension: ' . implode(', ', $incompatible));
222 }
223
224 foreach ($keys as $key) {
225 /** @var CRM_Extension_Info $info */
226 /** @var CRM_Extension_Manager_Base $typeManager */
227 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
228
229 switch ($origStatuses[$key]) {
230 case self::STATUS_INSTALLED:
231 // ok, nothing to do
232 break;
233
234 case self::STATUS_DISABLED:
235 // re-enable it
236 $typeManager->onPreEnable($info);
237 $this->_setExtensionActive($info, 1);
238 $typeManager->onPostEnable($info);
239
240 // A full refresh would be preferrable but very slow. This at least allows
241 // later extensions to access classes from earlier extensions.
242 $this->statuses = NULL;
243 $this->mapper->refresh();
244 break;
245
246 case self::STATUS_UNINSTALLED:
247 // install anew
248 $typeManager->onPreInstall($info);
249 $this->_createExtensionEntry($info);
250 $typeManager->onPostInstall($info);
251
252 // A full refresh would be preferrable but very slow. This at least allows
253 // later extensions to access classes from earlier extensions.
254 $this->statuses = NULL;
255 $this->mapper->refresh();
256 break;
257
258 case self::STATUS_UNKNOWN:
259 default:
260 throw new CRM_Extension_Exception("Cannot install or enable extension: $key");
261 }
262 }
263
264 $this->statuses = NULL;
265 $this->mapper->refresh();
266 CRM_Core_Invoke::rebuildMenuAndCaches(TRUE);
267 $schema = new CRM_Logging_Schema();
268 $schema->fixSchemaDifferences();
269
270 foreach ($keys as $key) {
271 // throws Exception
272 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
273
274 switch ($origStatuses[$key]) {
275 case self::STATUS_INSTALLED:
276 // ok, nothing to do
277 break;
278
279 case self::STATUS_DISABLED:
280 // re-enable it
281 break;
282
283 case self::STATUS_UNINSTALLED:
284 // install anew
285 $typeManager->onPostPostInstall($info);
286 break;
287
288 case self::STATUS_UNKNOWN:
289 default:
290 throw new CRM_Extension_Exception("Cannot install or enable extension: $key");
291 }
292 }
293
294 }
295
296 /**
297 * Add records of the extension to the database -- and enable it
298 *
299 * @param array $keys
300 * List of extension keys.
301 * @throws CRM_Extension_Exception
302 */
303 public function enable($keys) {
304 $this->install($keys);
305 }
306
307 /**
308 * Disable extension without removing record from db.
309 *
310 * @param string|array $keys
311 * One or more extension keys.
312 * @throws CRM_Extension_Exception
313 */
314 public function disable($keys) {
315 $keys = (array) $keys;
316 $origStatuses = $this->getStatuses();
317
318 // TODO: to mitigate the risk of crashing during installation, scan
319 // keys/statuses/types before doing anything
320
321 sort($keys);
322 $disableRequirements = $this->findDisableRequirements($keys);
323 // This munges order, but makes it comparable.
324 sort($disableRequirements);
325 if ($keys !== $disableRequirements) {
326 throw new CRM_Extension_Exception_DependencyException("Cannot disable extension due to dependencies. Consider disabling all these: " . implode(',', $disableRequirements));
327 }
328
329 foreach ($keys as $key) {
330 switch ($origStatuses[$key]) {
331 case self::STATUS_INSTALLED:
332 // throws Exception
333 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
334 $typeManager->onPreDisable($info);
335 $this->_setExtensionActive($info, 0);
336 $typeManager->onPostDisable($info);
337 break;
338
339 case self::STATUS_INSTALLED_MISSING:
340 // throws Exception
341 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
342 $typeManager->onPreDisable($info);
343 $this->_setExtensionActive($info, 0);
344 $typeManager->onPostDisable($info);
345 break;
346
347 case self::STATUS_DISABLED:
348 case self::STATUS_DISABLED_MISSING:
349 case self::STATUS_UNINSTALLED:
350 // ok, nothing to do
351 break;
352
353 case self::STATUS_UNKNOWN:
354 default:
355 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
356 }
357 }
358
359 $this->statuses = NULL;
360 $this->mapper->refresh();
361 CRM_Core_Invoke::rebuildMenuAndCaches(TRUE);
362 }
363
364 /**
365 * Remove all database references to an extension.
366 *
367 * @param string|array $keys
368 * One or more extension keys.
369 * @throws CRM_Extension_Exception
370 */
371 public function uninstall($keys) {
372 $keys = (array) $keys;
373 $origStatuses = $this->getStatuses();
374
375 // TODO: to mitigate the risk of crashing during installation, scan
376 // keys/statuses/types before doing anything
377
378 foreach ($keys as $key) {
379 switch ($origStatuses[$key]) {
380 case self::STATUS_INSTALLED:
381 case self::STATUS_INSTALLED_MISSING:
382 throw new CRM_Extension_Exception("Cannot uninstall extension; disable it first: $key");
383
384 case self::STATUS_DISABLED:
385 // throws Exception
386 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
387 $typeManager->onPreUninstall($info);
388 $this->_removeExtensionEntry($info);
389 $typeManager->onPostUninstall($info);
390 break;
391
392 case self::STATUS_DISABLED_MISSING:
393 // throws Exception
394 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
395 $typeManager->onPreUninstall($info);
396 $this->_removeExtensionEntry($info);
397 $typeManager->onPostUninstall($info);
398 break;
399
400 case self::STATUS_UNINSTALLED:
401 // ok, nothing to do
402 break;
403
404 case self::STATUS_UNKNOWN:
405 default:
406 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
407 }
408 }
409
410 $this->statuses = NULL;
411 $this->mapper->refresh();
412 CRM_Core_Invoke::rebuildMenuAndCaches(TRUE);
413 }
414
415 /**
416 * Determine the status of an extension.
417 *
418 * @param $key
419 *
420 * @return string
421 * constant self::STATUS_*
422 */
423 public function getStatus($key) {
424 $statuses = $this->getStatuses();
425 if (array_key_exists($key, $statuses)) {
426 return $statuses[$key];
427 }
428 else {
429 return self::STATUS_UNKNOWN;
430 }
431 }
432
433 /**
434 * Check if a given extension is incompatible with this version of CiviCRM
435 *
436 * @param $key
437 * @return bool|array
438 */
439 public function isIncompatible($key) {
440 $info = CRM_Extension_System::getCompatibilityInfo();
441 return $info[$key] ?? FALSE;
442 }
443
444 /**
445 * Determine the status of all extensions.
446 *
447 * @return array
448 * ($key => status_constant)
449 */
450 public function getStatuses() {
451 if (!is_array($this->statuses)) {
452 $compat = CRM_Extension_System::getCompatibilityInfo();
453
454 $this->statuses = [];
455
456 foreach ($this->fullContainer->getKeys() as $key) {
457 $this->statuses[$key] = self::STATUS_UNINSTALLED;
458 }
459
460 $sql = '
461 SELECT full_name, is_active
462 FROM civicrm_extension
463 ';
464 $dao = CRM_Core_DAO::executeQuery($sql);
465 while ($dao->fetch()) {
466 try {
467 $path = $this->fullContainer->getPath($dao->full_name);
468 $codeExists = !empty($path) && is_dir($path);
469 }
470 catch (CRM_Extension_Exception $e) {
471 $codeExists = FALSE;
472 }
473 if (!empty($compat[$dao->full_name]['force-uninstall'])) {
474 $this->statuses[$dao->full_name] = self::STATUS_UNINSTALLED;
475 }
476 elseif ($dao->is_active) {
477 $this->statuses[$dao->full_name] = $codeExists ? self::STATUS_INSTALLED : self::STATUS_INSTALLED_MISSING;
478 }
479 else {
480 $this->statuses[$dao->full_name] = $codeExists ? self::STATUS_DISABLED : self::STATUS_DISABLED_MISSING;
481 }
482 }
483 }
484 return $this->statuses;
485 }
486
487 public function refresh() {
488 $this->statuses = NULL;
489 // and, indirectly, defaultContainer
490 $this->fullContainer->refresh();
491 $this->mapper->refresh();
492 }
493
494 // ----------------------
495
496 /**
497 * Find the $info and $typeManager for a $key
498 *
499 * @param $key
500 *
501 * @throws CRM_Extension_Exception
502 * @return array
503 * [CRM_Extension_Info, CRM_Extension_Manager_Interface]
504 */
505 private function _getInfoTypeHandler($key) {
506 // throws Exception
507 $info = $this->mapper->keyToInfo($key);
508 if (array_key_exists($info->type, $this->typeManagers)) {
509 return [$info, $this->typeManagers[$info->type]];
510 }
511 else {
512 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type);
513 }
514 }
515
516 /**
517 * Find the $info and $typeManager for a $key
518 *
519 * @param $key
520 *
521 * @throws CRM_Extension_Exception
522 * @return array
523 * [CRM_Extension_Info, CRM_Extension_Manager_Interface]
524 */
525 private function _getMissingInfoTypeHandler($key) {
526 $info = $this->createInfoFromDB($key);
527 if ($info) {
528 if (array_key_exists($info->type, $this->typeManagers)) {
529 return [$info, $this->typeManagers[$info->type]];
530 }
531 else {
532 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type);
533 }
534 }
535 else {
536 throw new CRM_Extension_Exception("Failed to reconstruct missing extension: " . $key);
537 }
538 }
539
540 /**
541 * @param CRM_Extension_Info $info
542 *
543 * @return bool
544 */
545 private function _createExtensionEntry(CRM_Extension_Info $info) {
546 $dao = new CRM_Core_DAO_Extension();
547 $dao->label = $info->label;
548 $dao->name = $info->name;
549 $dao->full_name = $info->key;
550 $dao->type = $info->type;
551 $dao->file = $info->file;
552 $dao->is_active = 1;
553 return (bool) ($dao->insert());
554 }
555
556 /**
557 * @param CRM_Extension_Info $info
558 *
559 * @return bool
560 */
561 private function _updateExtensionEntry(CRM_Extension_Info $info) {
562 $dao = new CRM_Core_DAO_Extension();
563 $dao->full_name = $info->key;
564 if ($dao->find(TRUE)) {
565 $dao->label = $info->label;
566 $dao->name = $info->name;
567 $dao->full_name = $info->key;
568 $dao->type = $info->type;
569 $dao->file = $info->file;
570 $dao->is_active = 1;
571 return (bool) ($dao->update());
572 }
573 else {
574 return $this->_createExtensionEntry($info);
575 }
576 }
577
578 /**
579 * @param CRM_Extension_Info $info
580 *
581 * @throws CRM_Extension_Exception
582 */
583 private function _removeExtensionEntry(CRM_Extension_Info $info) {
584 $dao = new CRM_Core_DAO_Extension();
585 $dao->full_name = $info->key;
586 if ($dao->find(TRUE)) {
587 if (CRM_Core_BAO_Extension::del($dao->id)) {
588 CRM_Core_Session::setStatus(ts('Selected option value has been deleted.'), ts('Deleted'), 'success');
589 }
590 else {
591 throw new CRM_Extension_Exception("Failed to remove extension entry");
592 }
593 } // else: post-condition already satisified
594 }
595
596 /**
597 * @param CRM_Extension_Info $info
598 * @param $isActive
599 */
600 private function _setExtensionActive(CRM_Extension_Info $info, $isActive) {
601 CRM_Core_DAO::executeQuery('UPDATE civicrm_extension SET is_active = %1 where full_name = %2', [
602 1 => [$isActive, 'Integer'],
603 2 => [$info->key, 'String'],
604 ]);
605 }
606
607 /**
608 * Auto-generate a place-holder for a missing extension using info from
609 * database.
610 *
611 * @param $key
612 * @return CRM_Extension_Info|NULL
613 */
614 public function createInfoFromDB($key) {
615 $dao = new CRM_Core_DAO_Extension();
616 $dao->full_name = $key;
617 if ($dao->find(TRUE)) {
618 $info = new CRM_Extension_Info($dao->full_name, $dao->type, $dao->name, $dao->label, $dao->file);
619 return $info;
620 }
621 else {
622 return NULL;
623 }
624 }
625
626 /**
627 * Build a list of extensions to install, in an order that will satisfy dependencies.
628 *
629 * @param array $keys
630 * List of extensions to install.
631 * @param \CRM_Extension_Info $info
632 * An extension info object that we should use instead of our local versions (eg. when checking for upgradeability).
633 *
634 * @return array
635 * List of extension keys, including dependencies, in order of installation.
636 * @throws \CRM_Extension_Exception
637 * @throws \MJS\TopSort\CircularDependencyException
638 * @throws \MJS\TopSort\ElementNotFoundException
639 */
640 public function findInstallRequirements($keys, $info = NULL) {
641 // Use our passed in info, or get the local versions
642 if ($info) {
643 $infos[$info->key] = $info;
644 }
645 else {
646 $infos = $this->mapper->getAllInfos();
647 }
648 // array(string $key).
649 $todoKeys = array_unique($keys);
650 // array(string $key => 1);
651 $doneKeys = [];
652 $sorter = new \MJS\TopSort\Implementations\FixedArraySort();
653
654 while (!empty($todoKeys)) {
655 $key = array_shift($todoKeys);
656 if (isset($doneKeys[$key])) {
657 continue;
658 }
659 $doneKeys[$key] = 1;
660
661 /** @var CRM_Extension_Info $info */
662 $info = @$infos[$key];
663
664 if ($info && $info->requires) {
665 $sorter->add($key, $info->requires);
666 $todoKeys = array_merge($todoKeys, $info->requires);
667 }
668 else {
669 $sorter->add($key, []);
670 }
671 }
672 return $sorter->sort();
673 }
674
675 /**
676 * Build a list of extensions to remove, in an order that will satisfy dependencies.
677 *
678 * @param array $keys
679 * List of extensions to install.
680 * @return array
681 * List of extension keys, including dependencies, in order of removal.
682 */
683 public function findDisableRequirements($keys) {
684 $INSTALLED = [
685 self::STATUS_INSTALLED,
686 self::STATUS_INSTALLED_MISSING,
687 ];
688 $installedInfos = $this->filterInfosByStatus($this->mapper->getAllInfos(), $INSTALLED);
689 $revMap = CRM_Extension_Info::buildReverseMap($installedInfos);
690 $todoKeys = array_unique($keys);
691 $doneKeys = [];
692 $sorter = new \MJS\TopSort\Implementations\FixedArraySort();
693
694 while (!empty($todoKeys)) {
695 $key = array_shift($todoKeys);
696 if (isset($doneKeys[$key])) {
697 continue;
698 }
699 $doneKeys[$key] = 1;
700
701 if (isset($revMap[$key])) {
702 $requiredBys = CRM_Utils_Array::collect('key',
703 $this->filterInfosByStatus($revMap[$key], $INSTALLED));
704 $sorter->add($key, $requiredBys);
705 $todoKeys = array_merge($todoKeys, $requiredBys);
706 }
707 else {
708 $sorter->add($key, []);
709 }
710 }
711 return $sorter->sort();
712 }
713
714 /**
715 * @param $infos
716 * @param $filterStatuses
717 * @return array
718 */
719 protected function filterInfosByStatus($infos, $filterStatuses) {
720 $matches = [];
721 foreach ($infos as $k => $v) {
722 if (in_array($this->getStatus($v->key), $filterStatuses)) {
723 $matches[$k] = $v;
724 }
725 }
726 return $matches;
727 }
728
729 }