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