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 fully installed and enabled
59 const STATUS_INSTALLED_MISSING
= 'installed-missing';
62 * The extension is fully installed and enabled
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;
74 * @var CRM_Extension_Container_Basic|FALSE
76 * Note: Treat as private. This is only public to facilitate debugging.
78 public $defaultContainer;
81 * @var CRM_Extension_Mapper
83 * Note: Treat as private. This is only public to facilitate debugging.
88 * @var array (typeName => CRM_Extension_Manager_Interface)
90 * Note: Treat as private. This is only public to facilitate debugging.
95 * @var array (extensionKey => statusConstant)
97 * Note: Treat as private. This is only public to facilitate debugging.
102 * @param CRM_Extension_Container_Interface $fullContainer
103 * @param CRM_Extension_Container_Basic|FALSE $defaultContainer
104 * @param CRM_Extension_Mapper $mapper
105 * @param $typeManagers
107 public function __construct(CRM_Extension_Container_Interface
$fullContainer, $defaultContainer, CRM_Extension_Mapper
$mapper, $typeManagers) {
108 $this->fullContainer
= $fullContainer;
109 $this->defaultContainer
= $defaultContainer;
110 $this->mapper
= $mapper;
111 $this->typeManagers
= $typeManagers;
115 * Install or upgrade the code for an extension -- and perform any
116 * necessary database changes (eg replacing extension metadata).
118 * This only works if the extension is stored in the default container.
120 * @param string $tmpCodeDir
121 * Path to a local directory containing a copy of the new (inert) code.
122 * @throws CRM_Extension_Exception
124 public function replace($tmpCodeDir) {
125 if (!$this->defaultContainer
) {
126 throw new CRM_Extension_Exception("Default extension container is not configured");
129 $newInfo = CRM_Extension_Info
::loadFromFile($tmpCodeDir . DIRECTORY_SEPARATOR
. CRM_Extension_Info
::FILENAME
);
130 $oldStatus = $this->getStatus($newInfo->key
);
132 // find $tgtPath, $oldInfo, $typeManager
133 switch ($oldStatus) {
134 case self
::STATUS_UNINSTALLED
:
135 case self
::STATUS_INSTALLED
:
136 case self
::STATUS_DISABLED
:
137 // There is an old copy of the extension. Try to install in the same place -- but it must go somewhere in the default-container
139 list ($oldInfo, $typeManager) = $this->_getInfoTypeHandler($newInfo->key
);
140 $tgtPath = $this->fullContainer
->getPath($newInfo->key
);
141 if (!CRM_Utils_File
::isChildPath($this->defaultContainer
->getBaseDir(), $tgtPath)) {
142 // force installation in the default-container
144 $tgtPath = $this->defaultContainer
->getBaseDir() . DIRECTORY_SEPARATOR
. $newInfo->key
;
145 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.', [
152 case self
::STATUS_INSTALLED_MISSING
:
153 case self
::STATUS_DISABLED_MISSING
:
154 // the extension does not exist in any container; we're free to put it anywhere
155 $tgtPath = $this->defaultContainer
->getBaseDir() . DIRECTORY_SEPARATOR
. $newInfo->key
;
157 list ($oldInfo, $typeManager) = $this->_getMissingInfoTypeHandler($newInfo->key
);
160 case self
::STATUS_UNKNOWN
:
161 // the extension does not exist in any container; we're free to put it anywhere
162 $tgtPath = $this->defaultContainer
->getBaseDir() . DIRECTORY_SEPARATOR
. $newInfo->key
;
163 $oldInfo = $typeManager = NULL;
167 throw new CRM_Extension_Exception("Cannot install or enable extension: {$newInfo->key}");
171 switch ($oldStatus) {
172 case self
::STATUS_UNINSTALLED
:
173 case self
::STATUS_UNKNOWN
:
174 // There are no DB records to worry about, so we'll just put the files in place
175 if (!CRM_Utils_File
::replaceDir($tmpCodeDir, $tgtPath)) {
176 throw new CRM_Extension_Exception("Failed to move $tmpCodeDir to $tgtPath");
180 case self
::STATUS_INSTALLED
:
181 case self
::STATUS_INSTALLED_MISSING
:
182 case self
::STATUS_DISABLED
:
183 case self
::STATUS_DISABLED_MISSING
:
184 // There are DB records; coordinate the file placement with the DB updates
185 $typeManager->onPreReplace($oldInfo, $newInfo);
186 if (!CRM_Utils_File
::replaceDir($tmpCodeDir, $tgtPath)) {
187 throw new CRM_Extension_Exception("Failed to move $tmpCodeDir to $tgtPath");
189 $this->_updateExtensionEntry($newInfo);
190 $typeManager->onPostReplace($oldInfo, $newInfo);
194 throw new CRM_Extension_Exception("Cannot install or enable extension: {$newInfo->key}");
198 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
202 * Add records of the extension to the database -- and enable it
205 * List of extension keys.
206 * @throws CRM_Extension_Exception
208 public function install($keys) {
209 $origStatuses = $this->getStatuses();
211 // TODO: to mitigate the risk of crashing during installation, scan
212 // keys/statuses/types before doing anything
214 foreach ($keys as $key) {
216 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
218 switch ($origStatuses[$key]) {
219 case self
::STATUS_INSTALLED
:
223 case self
::STATUS_DISABLED
:
225 $typeManager->onPreEnable($info);
226 $this->_setExtensionActive($info, 1);
227 $typeManager->onPostEnable($info);
229 // A full refresh would be preferrable but very slow. This at least allows
230 // later extensions to access classes from earlier extensions.
231 $this->statuses
= NULL;
232 $this->mapper
->refresh();
235 case self
::STATUS_UNINSTALLED
:
237 $typeManager->onPreInstall($info);
238 $this->_createExtensionEntry($info);
239 $typeManager->onPostInstall($info);
241 // A full refresh would be preferrable but very slow. This at least allows
242 // later extensions to access classes from earlier extensions.
243 $this->statuses
= NULL;
244 $this->mapper
->refresh();
247 case self
::STATUS_UNKNOWN
:
249 throw new CRM_Extension_Exception("Cannot install or enable extension: $key");
253 $this->statuses
= NULL;
254 $this->mapper
->refresh();
255 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
256 $schema = new CRM_Logging_Schema();
257 $schema->fixSchemaDifferences();
259 foreach ($keys as $key) {
261 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
263 switch ($origStatuses[$key]) {
264 case self
::STATUS_INSTALLED
:
268 case self
::STATUS_DISABLED
:
272 case self
::STATUS_UNINSTALLED
:
274 $typeManager->onPostPostInstall($info);
277 case self
::STATUS_UNKNOWN
:
279 throw new CRM_Extension_Exception("Cannot install or enable extension: $key");
286 * Add records of the extension to the database -- and enable it
289 * List of extension keys.
290 * @throws CRM_Extension_Exception
292 public function enable($keys) {
293 $this->install($keys);
297 * Add records of the extension to the database -- and enable it
300 * List of extension keys.
301 * @throws CRM_Extension_Exception
303 public function disable($keys) {
304 $origStatuses = $this->getStatuses();
306 // TODO: to mitigate the risk of crashing during installation, scan
307 // keys/statuses/types before doing anything
310 $disableRequirements = $this->findDisableRequirements($keys);
311 // This munges order, but makes it comparable.
312 sort($disableRequirements);
313 if ($keys !== $disableRequirements) {
314 throw new CRM_Extension_Exception_DependencyException("Cannot disable extension due dependencies. Consider disabling all these: " . implode(',', $disableRequirements));
317 foreach ($keys as $key) {
318 switch ($origStatuses[$key]) {
319 case self
::STATUS_INSTALLED
:
321 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
322 $typeManager->onPreDisable($info);
323 $this->_setExtensionActive($info, 0);
324 $typeManager->onPostDisable($info);
327 case self
::STATUS_INSTALLED_MISSING
:
329 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
330 $typeManager->onPreDisable($info);
331 $this->_setExtensionActive($info, 0);
332 $typeManager->onPostDisable($info);
335 case self
::STATUS_DISABLED
:
336 case self
::STATUS_DISABLED_MISSING
:
337 case self
::STATUS_UNINSTALLED
:
341 case self
::STATUS_UNKNOWN
:
343 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
347 $this->statuses
= NULL;
348 $this->mapper
->refresh();
349 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
353 * Remove all database references to an extension.
355 * Add records of the extension to the database -- and enable it
358 * List of extension keys.
359 * @throws CRM_Extension_Exception
361 public function uninstall($keys) {
362 $origStatuses = $this->getStatuses();
364 // TODO: to mitigate the risk of crashing during installation, scan
365 // keys/statuses/types before doing anything
367 foreach ($keys as $key) {
368 switch ($origStatuses[$key]) {
369 case self
::STATUS_INSTALLED
:
370 case self
::STATUS_INSTALLED_MISSING
:
371 throw new CRM_Extension_Exception("Cannot uninstall extension; disable it first: $key");
373 case self
::STATUS_DISABLED
:
375 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
376 $typeManager->onPreUninstall($info);
377 $this->_removeExtensionEntry($info);
378 $typeManager->onPostUninstall($info);
381 case self
::STATUS_DISABLED_MISSING
:
383 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
384 $typeManager->onPreUninstall($info);
385 $this->_removeExtensionEntry($info);
386 $typeManager->onPostUninstall($info);
389 case self
::STATUS_UNINSTALLED
:
393 case self
::STATUS_UNKNOWN
:
395 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
399 $this->statuses
= NULL;
400 $this->mapper
->refresh();
401 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
405 * Determine the status of an extension.
410 * constant (STATUS_INSTALLED, STATUS_DISABLED, STATUS_UNINSTALLED, STATUS_UNKNOWN)
412 public function getStatus($key) {
413 $statuses = $this->getStatuses();
414 if (array_key_exists($key, $statuses)) {
415 return $statuses[$key];
418 return self
::STATUS_UNKNOWN
;
423 * Determine the status of all extensions.
426 * ($key => status_constant)
428 public function getStatuses() {
429 if (!is_array($this->statuses
)) {
430 $this->statuses
= [];
432 foreach ($this->fullContainer
->getKeys() as $key) {
433 $this->statuses
[$key] = self
::STATUS_UNINSTALLED
;
437 SELECT full_name, is_active
438 FROM civicrm_extension
440 $dao = CRM_Core_DAO
::executeQuery($sql);
441 while ($dao->fetch()) {
443 $path = $this->fullContainer
->getPath($dao->full_name
);
444 $codeExists = !empty($path) && is_dir($path);
446 catch (CRM_Extension_Exception
$e) {
449 if ($dao->is_active
) {
450 $this->statuses
[$dao->full_name
] = $codeExists ? self
::STATUS_INSTALLED
: self
::STATUS_INSTALLED_MISSING
;
453 $this->statuses
[$dao->full_name
] = $codeExists ? self
::STATUS_DISABLED
: self
::STATUS_DISABLED_MISSING
;
457 return $this->statuses
;
460 public function refresh() {
461 $this->statuses
= NULL;
462 // and, indirectly, defaultContainer
463 $this->fullContainer
->refresh();
464 $this->mapper
->refresh();
467 // ----------------------
470 * Find the $info and $typeManager for a $key
474 * @throws CRM_Extension_Exception
476 * (0 => CRM_Extension_Info, 1 => CRM_Extension_Manager_Interface)
478 private function _getInfoTypeHandler($key) {
480 $info = $this->mapper
->keyToInfo($key);
481 if (array_key_exists($info->type
, $this->typeManagers
)) {
482 return [$info, $this->typeManagers
[$info->type
]];
485 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type
);
490 * Find the $info and $typeManager for a $key
494 * @throws CRM_Extension_Exception
496 * (0 => CRM_Extension_Info, 1 => CRM_Extension_Manager_Interface)
498 private function _getMissingInfoTypeHandler($key) {
499 $info = $this->createInfoFromDB($key);
501 if (array_key_exists($info->type
, $this->typeManagers
)) {
502 return [$info, $this->typeManagers
[$info->type
]];
505 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type
);
509 throw new CRM_Extension_Exception("Failed to reconstruct missing extension: " . $key);
514 * @param CRM_Extension_Info $info
518 private function _createExtensionEntry(CRM_Extension_Info
$info) {
519 $dao = new CRM_Core_DAO_Extension();
520 $dao->label
= $info->label
;
521 $dao->name
= $info->name
;
522 $dao->full_name
= $info->key
;
523 $dao->type
= $info->type
;
524 $dao->file
= $info->file
;
526 return (bool) ($dao->insert());
530 * @param CRM_Extension_Info $info
534 private function _updateExtensionEntry(CRM_Extension_Info
$info) {
535 $dao = new CRM_Core_DAO_Extension();
536 $dao->full_name
= $info->key
;
537 if ($dao->find(TRUE)) {
538 $dao->label
= $info->label
;
539 $dao->name
= $info->name
;
540 $dao->full_name
= $info->key
;
541 $dao->type
= $info->type
;
542 $dao->file
= $info->file
;
544 return (bool) ($dao->update());
547 return $this->_createExtensionEntry($info);
552 * @param CRM_Extension_Info $info
554 * @throws CRM_Extension_Exception
556 private function _removeExtensionEntry(CRM_Extension_Info
$info) {
557 $dao = new CRM_Core_DAO_Extension();
558 $dao->full_name
= $info->key
;
559 if ($dao->find(TRUE)) {
560 if (CRM_Core_BAO_Extension
::del($dao->id
)) {
561 CRM_Core_Session
::setStatus(ts('Selected option value has been deleted.'), ts('Deleted'), 'success');
564 throw new CRM_Extension_Exception("Failed to remove extension entry");
566 } // else: post-condition already satisified
570 * @param CRM_Extension_Info $info
573 private function _setExtensionActive(CRM_Extension_Info
$info, $isActive) {
574 CRM_Core_DAO
::executeQuery('UPDATE civicrm_extension SET is_active = %1 where full_name = %2', [
575 1 => [$isActive, 'Integer'],
576 2 => [$info->key
, 'String'],
581 * Auto-generate a place-holder for a missing extension using info from
585 * @return CRM_Extension_Info|NULL
587 public function createInfoFromDB($key) {
588 $dao = new CRM_Core_DAO_Extension();
589 $dao->full_name
= $key;
590 if ($dao->find(TRUE)) {
591 $info = new CRM_Extension_Info($dao->full_name
, $dao->type
, $dao->name
, $dao->label
, $dao->file
);
600 * Build a list of extensions to install, in an order that will satisfy dependencies.
603 * List of extensions to install.
605 * List of extension keys, including dependencies, in order of installation.
607 public function findInstallRequirements($keys) {
608 $infos = $this->mapper
->getAllInfos();
609 // array(string $key).
610 $todoKeys = array_unique($keys);
611 // array(string $key => 1);
613 $sorter = new \MJS\TopSort\Implementations\
FixedArraySort();
615 while (!empty($todoKeys)) {
616 $key = array_shift($todoKeys);
617 if (isset($doneKeys[$key])) {
622 /** @var CRM_Extension_Info $info */
623 $info = @$infos[$key];
625 if ($this->getStatus($key) === self
::STATUS_INSTALLED
) {
626 $sorter->add($key, []);
628 elseif ($info && $info->requires
) {
629 $sorter->add($key, $info->requires
);
630 $todoKeys = array_merge($todoKeys, $info->requires
);
633 $sorter->add($key, []);
636 return $sorter->sort();
640 * Build a list of extensions to remove, in an order that will satisfy dependencies.
643 * List of extensions to install.
645 * List of extension keys, including dependencies, in order of removal.
647 public function findDisableRequirements($keys) {
649 self
::STATUS_INSTALLED
,
650 self
::STATUS_INSTALLED_MISSING
,
652 $installedInfos = $this->filterInfosByStatus($this->mapper
->getAllInfos(), $INSTALLED);
653 $revMap = CRM_Extension_Info
::buildReverseMap($installedInfos);
654 $todoKeys = array_unique($keys);
656 $sorter = new \MJS\TopSort\Implementations\
FixedArraySort();
658 while (!empty($todoKeys)) {
659 $key = array_shift($todoKeys);
660 if (isset($doneKeys[$key])) {
665 if (isset($revMap[$key])) {
666 $requiredBys = CRM_Utils_Array
::collect('key',
667 $this->filterInfosByStatus($revMap[$key], $INSTALLED));
668 $sorter->add($key, $requiredBys);
669 $todoKeys = array_merge($todoKeys, $requiredBys);
672 $sorter->add($key, []);
675 return $sorter->sort();
680 * @param $filterStatuses
683 protected function filterInfosByStatus($infos, $filterStatuses) {
685 foreach ($infos as $k => $v) {
686 if (in_array($this->getStatus($v->key
), $filterStatuses)) {