3 +--------------------------------------------------------------------+
5 +--------------------------------------------------------------------+
6 | Copyright CiviCRM LLC (c) 2004-2019 |
7 +--------------------------------------------------------------------+
8 | This file is a part of CiviCRM. |
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. |
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. |
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 +--------------------------------------------------------------------+
29 * The extension manager handles installing, disabling enabling, and
30 * uninstalling extensions.
33 * @copyright CiviCRM LLC (c) 2004-2019
35 class CRM_Extension_Manager
{
37 * The extension is fully installed and enabled.
39 const STATUS_INSTALLED
= 'installed';
42 * The extension config has been applied to database but deactivated.
44 const STATUS_DISABLED
= 'disabled';
47 * The extension code is visible, but nothing has been applied to DB
49 const STATUS_UNINSTALLED
= 'uninstalled';
52 * The extension code is not locally accessible
54 const STATUS_UNKNOWN
= 'unknown';
57 * The extension is installed but the code is not accessible
59 const STATUS_INSTALLED_MISSING
= 'installed-missing';
62 * The extension was installed and is now disabled; the code is not accessible
64 const STATUS_DISABLED_MISSING
= 'disabled-missing';
67 * @var CRM_Extension_Container_Interface
69 * Note: Treat as private. This is only public to facilitate debugging.
71 public $fullContainer;
76 * @var CRM_Extension_Container_Basic|false
78 * Note: Treat as private. This is only public to facilitate debugging.
80 public $defaultContainer;
85 * @var CRM_Extension_Mapper
87 * Note: Treat as private. This is only public to facilitate debugging.
96 * Format is (typeName => CRM_Extension_Manager_Interface)
98 * Note: Treat as private. This is only public to facilitate debugging.
100 public $typeManagers;
107 * Format is (extensionKey => statusConstant)
109 * Note: Treat as private. This is only public to facilitate debugging.
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
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;
129 * Install or upgrade the code for an extension -- and perform any
130 * necessary database changes (eg replacing extension metadata).
132 * This only works if the extension is stored in the default container.
134 * @param string $tmpCodeDir
135 * Path to a local directory containing a copy of the new (inert) code.
136 * @throws CRM_Extension_Exception
138 public function replace($tmpCodeDir) {
139 if (!$this->defaultContainer
) {
140 throw new CRM_Extension_Exception("Default extension container is not configured");
143 $newInfo = CRM_Extension_Info
::loadFromFile($tmpCodeDir . DIRECTORY_SEPARATOR
. CRM_Extension_Info
::FILENAME
);
144 $oldStatus = $this->getStatus($newInfo->key
);
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
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
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.', [
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
;
171 list ($oldInfo, $typeManager) = $this->_getMissingInfoTypeHandler($newInfo->key
);
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;
181 throw new CRM_Extension_Exception("Cannot install or enable extension: {$newInfo->key}");
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");
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");
203 $this->_updateExtensionEntry($newInfo);
204 $typeManager->onPostReplace($oldInfo, $newInfo);
208 throw new CRM_Extension_Exception("Cannot install or enable extension: {$newInfo->key}");
212 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
216 * Add records of the extension to the database -- and enable it
218 * @param string|array $keys
219 * One or more extension keys.
220 * @throws CRM_Extension_Exception
222 public function install($keys) {
223 $keys = (array) $keys;
224 $origStatuses = $this->getStatuses();
226 // TODO: to mitigate the risk of crashing during installation, scan
227 // keys/statuses/types before doing anything
229 // Check compatibility
231 foreach ($keys as $key) {
232 if ($this->isIncompatible($key)) {
233 $incompatible[] = $key;
237 throw new CRM_Extension_Exception('Cannot install incompatible extension: ' . implode(', ', $incompatible));
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);
245 switch ($origStatuses[$key]) {
246 case self
::STATUS_INSTALLED
:
250 case self
::STATUS_DISABLED
:
252 $typeManager->onPreEnable($info);
253 $this->_setExtensionActive($info, 1);
254 $typeManager->onPostEnable($info);
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();
262 case self
::STATUS_UNINSTALLED
:
264 $typeManager->onPreInstall($info);
265 $this->_createExtensionEntry($info);
266 $typeManager->onPostInstall($info);
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();
274 case self
::STATUS_UNKNOWN
:
276 throw new CRM_Extension_Exception("Cannot install or enable extension: $key");
280 $this->statuses
= NULL;
281 $this->mapper
->refresh();
282 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
283 $schema = new CRM_Logging_Schema();
284 $schema->fixSchemaDifferences();
286 foreach ($keys as $key) {
288 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
290 switch ($origStatuses[$key]) {
291 case self
::STATUS_INSTALLED
:
295 case self
::STATUS_DISABLED
:
299 case self
::STATUS_UNINSTALLED
:
301 $typeManager->onPostPostInstall($info);
304 case self
::STATUS_UNKNOWN
:
306 throw new CRM_Extension_Exception("Cannot install or enable extension: $key");
313 * Add records of the extension to the database -- and enable it
316 * List of extension keys.
317 * @throws CRM_Extension_Exception
319 public function enable($keys) {
320 $this->install($keys);
324 * Disable extension without removing record from db.
326 * @param string|array $keys
327 * One or more extension keys.
328 * @throws CRM_Extension_Exception
330 public function disable($keys) {
331 $keys = (array) $keys;
332 $origStatuses = $this->getStatuses();
334 // TODO: to mitigate the risk of crashing during installation, scan
335 // keys/statuses/types before doing anything
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 dependencies. Consider disabling all these: " . implode(',', $disableRequirements));
345 foreach ($keys as $key) {
346 switch ($origStatuses[$key]) {
347 case self
::STATUS_INSTALLED
:
349 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
350 $typeManager->onPreDisable($info);
351 $this->_setExtensionActive($info, 0);
352 $typeManager->onPostDisable($info);
355 case self
::STATUS_INSTALLED_MISSING
:
357 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
358 $typeManager->onPreDisable($info);
359 $this->_setExtensionActive($info, 0);
360 $typeManager->onPostDisable($info);
363 case self
::STATUS_DISABLED
:
364 case self
::STATUS_DISABLED_MISSING
:
365 case self
::STATUS_UNINSTALLED
:
369 case self
::STATUS_UNKNOWN
:
371 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
375 $this->statuses
= NULL;
376 $this->mapper
->refresh();
377 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
381 * Remove all database references to an extension.
383 * @param string|array $keys
384 * One or more extension keys.
385 * @throws CRM_Extension_Exception
387 public function uninstall($keys) {
388 $keys = (array) $keys;
389 $origStatuses = $this->getStatuses();
391 // TODO: to mitigate the risk of crashing during installation, scan
392 // keys/statuses/types before doing anything
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");
400 case self
::STATUS_DISABLED
:
402 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
403 $typeManager->onPreUninstall($info);
404 $this->_removeExtensionEntry($info);
405 $typeManager->onPostUninstall($info);
408 case self
::STATUS_DISABLED_MISSING
:
410 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
411 $typeManager->onPreUninstall($info);
412 $this->_removeExtensionEntry($info);
413 $typeManager->onPostUninstall($info);
416 case self
::STATUS_UNINSTALLED
:
420 case self
::STATUS_UNKNOWN
:
422 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
426 $this->statuses
= NULL;
427 $this->mapper
->refresh();
428 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
432 * Determine the status of an extension.
437 * constant self::STATUS_*
439 public function getStatus($key) {
440 $statuses = $this->getStatuses();
441 if (array_key_exists($key, $statuses)) {
442 return $statuses[$key];
445 return self
::STATUS_UNKNOWN
;
450 * Check if a given extension is incompatible with this version of CiviCRM
455 public function isIncompatible($key) {
456 $info = CRM_Extension_System
::getCompatibilityInfo();
457 return $info[$key] ??
FALSE;
461 * Determine the status of all extensions.
464 * ($key => status_constant)
466 public function getStatuses() {
467 if (!is_array($this->statuses
)) {
468 $compat = CRM_Extension_System
::getCompatibilityInfo();
470 $this->statuses
= [];
472 foreach ($this->fullContainer
->getKeys() as $key) {
473 $this->statuses
[$key] = self
::STATUS_UNINSTALLED
;
477 SELECT full_name, is_active
478 FROM civicrm_extension
480 $dao = CRM_Core_DAO
::executeQuery($sql);
481 while ($dao->fetch()) {
483 $path = $this->fullContainer
->getPath($dao->full_name
);
484 $codeExists = !empty($path) && is_dir($path);
486 catch (CRM_Extension_Exception
$e) {
489 if (!empty($compat[$dao->full_name
]['force-uninstall'])) {
490 $this->statuses
[$dao->full_name
] = self
::STATUS_UNINSTALLED
;
492 elseif ($dao->is_active
) {
493 $this->statuses
[$dao->full_name
] = $codeExists ? self
::STATUS_INSTALLED
: self
::STATUS_INSTALLED_MISSING
;
496 $this->statuses
[$dao->full_name
] = $codeExists ? self
::STATUS_DISABLED
: self
::STATUS_DISABLED_MISSING
;
500 return $this->statuses
;
503 public function refresh() {
504 $this->statuses
= NULL;
505 // and, indirectly, defaultContainer
506 $this->fullContainer
->refresh();
507 $this->mapper
->refresh();
510 // ----------------------
513 * Find the $info and $typeManager for a $key
517 * @throws CRM_Extension_Exception
519 * [CRM_Extension_Info, CRM_Extension_Manager_Interface]
521 private function _getInfoTypeHandler($key) {
523 $info = $this->mapper
->keyToInfo($key);
524 if (array_key_exists($info->type
, $this->typeManagers
)) {
525 return [$info, $this->typeManagers
[$info->type
]];
528 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type
);
533 * Find the $info and $typeManager for a $key
537 * @throws CRM_Extension_Exception
539 * [CRM_Extension_Info, CRM_Extension_Manager_Interface]
541 private function _getMissingInfoTypeHandler($key) {
542 $info = $this->createInfoFromDB($key);
544 if (array_key_exists($info->type
, $this->typeManagers
)) {
545 return [$info, $this->typeManagers
[$info->type
]];
548 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type
);
552 throw new CRM_Extension_Exception("Failed to reconstruct missing extension: " . $key);
557 * @param CRM_Extension_Info $info
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
;
569 return (bool) ($dao->insert());
573 * @param CRM_Extension_Info $info
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
;
587 return (bool) ($dao->update());
590 return $this->_createExtensionEntry($info);
595 * @param CRM_Extension_Info $info
597 * @throws CRM_Extension_Exception
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');
607 throw new CRM_Extension_Exception("Failed to remove extension entry");
609 } // else: post-condition already satisified
613 * @param CRM_Extension_Info $info
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'],
624 * Auto-generate a place-holder for a missing extension using info from
628 * @return CRM_Extension_Info|NULL
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
);
643 * Build a list of extensions to install, in an order that will satisfy dependencies.
646 * List of extensions to install.
648 * List of extension keys, including dependencies, in order of installation.
650 public function findInstallRequirements($keys) {
651 $infos = $this->mapper
->getAllInfos();
652 // array(string $key).
653 $todoKeys = array_unique($keys);
654 // array(string $key => 1);
656 $sorter = new \MJS\TopSort\Implementations\
FixedArraySort();
658 while (!empty($todoKeys)) {
659 $key = array_shift($todoKeys);
660 if (isset($doneKeys[$key])) {
665 /** @var CRM_Extension_Info $info */
666 $info = @$infos[$key];
668 if ($this->getStatus($key) === self
::STATUS_INSTALLED
) {
669 $sorter->add($key, []);
671 elseif ($info && $info->requires
) {
672 $sorter->add($key, $info->requires
);
673 $todoKeys = array_merge($todoKeys, $info->requires
);
676 $sorter->add($key, []);
679 return $sorter->sort();
683 * Build a list of extensions to remove, in an order that will satisfy dependencies.
686 * List of extensions to install.
688 * List of extension keys, including dependencies, in order of removal.
690 public function findDisableRequirements($keys) {
692 self
::STATUS_INSTALLED
,
693 self
::STATUS_INSTALLED_MISSING
,
695 $installedInfos = $this->filterInfosByStatus($this->mapper
->getAllInfos(), $INSTALLED);
696 $revMap = CRM_Extension_Info
::buildReverseMap($installedInfos);
697 $todoKeys = array_unique($keys);
699 $sorter = new \MJS\TopSort\Implementations\
FixedArraySort();
701 while (!empty($todoKeys)) {
702 $key = array_shift($todoKeys);
703 if (isset($doneKeys[$key])) {
708 if (isset($revMap[$key])) {
709 $requiredBys = CRM_Utils_Array
::collect('key',
710 $this->filterInfosByStatus($revMap[$key], $INSTALLED));
711 $sorter->add($key, $requiredBys);
712 $todoKeys = array_merge($todoKeys, $requiredBys);
715 $sorter->add($key, []);
718 return $sorter->sort();
723 * @param $filterStatuses
726 protected function filterInfosByStatus($infos, $filterStatuses) {
728 foreach ($infos as $k => $v) {
729 if (in_array($this->getStatus($v->key
), $filterStatuses)) {