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;
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
219 * List of extension keys.
220 * @throws CRM_Extension_Exception
222 public function install($keys) {
223 $origStatuses = $this->getStatuses();
225 // TODO: to mitigate the risk of crashing during installation, scan
226 // keys/statuses/types before doing anything
228 foreach ($keys as $key) {
230 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
232 switch ($origStatuses[$key]) {
233 case self
::STATUS_INSTALLED
:
237 case self
::STATUS_DISABLED
:
239 $typeManager->onPreEnable($info);
240 $this->_setExtensionActive($info, 1);
241 $typeManager->onPostEnable($info);
243 // A full refresh would be preferrable but very slow. This at least allows
244 // later extensions to access classes from earlier extensions.
245 $this->statuses
= NULL;
246 $this->mapper
->refresh();
249 case self
::STATUS_UNINSTALLED
:
251 $typeManager->onPreInstall($info);
252 $this->_createExtensionEntry($info);
253 $typeManager->onPostInstall($info);
255 // A full refresh would be preferrable but very slow. This at least allows
256 // later extensions to access classes from earlier extensions.
257 $this->statuses
= NULL;
258 $this->mapper
->refresh();
261 case self
::STATUS_UNKNOWN
:
263 throw new CRM_Extension_Exception("Cannot install or enable extension: $key");
267 $this->statuses
= NULL;
268 $this->mapper
->refresh();
269 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
270 $schema = new CRM_Logging_Schema();
271 $schema->fixSchemaDifferences();
273 foreach ($keys as $key) {
275 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
277 switch ($origStatuses[$key]) {
278 case self
::STATUS_INSTALLED
:
282 case self
::STATUS_DISABLED
:
286 case self
::STATUS_UNINSTALLED
:
288 $typeManager->onPostPostInstall($info);
291 case self
::STATUS_UNKNOWN
:
293 throw new CRM_Extension_Exception("Cannot install or enable extension: $key");
300 * Add records of the extension to the database -- and enable it
303 * List of extension keys.
304 * @throws CRM_Extension_Exception
306 public function enable($keys) {
307 $this->install($keys);
311 * Add records of the extension to the database -- and enable it
314 * List of extension keys.
315 * @throws CRM_Extension_Exception
317 public function disable($keys) {
318 $origStatuses = $this->getStatuses();
320 // TODO: to mitigate the risk of crashing during installation, scan
321 // keys/statuses/types before doing anything
324 $disableRequirements = $this->findDisableRequirements($keys);
325 // This munges order, but makes it comparable.
326 sort($disableRequirements);
327 if ($keys !== $disableRequirements) {
328 throw new CRM_Extension_Exception_DependencyException("Cannot disable extension due dependencies. Consider disabling all these: " . implode(',', $disableRequirements));
331 foreach ($keys as $key) {
332 switch ($origStatuses[$key]) {
333 case self
::STATUS_INSTALLED
:
335 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
336 $typeManager->onPreDisable($info);
337 $this->_setExtensionActive($info, 0);
338 $typeManager->onPostDisable($info);
341 case self
::STATUS_INSTALLED_MISSING
:
343 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
344 $typeManager->onPreDisable($info);
345 $this->_setExtensionActive($info, 0);
346 $typeManager->onPostDisable($info);
349 case self
::STATUS_DISABLED
:
350 case self
::STATUS_DISABLED_MISSING
:
351 case self
::STATUS_UNINSTALLED
:
355 case self
::STATUS_UNKNOWN
:
357 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
361 $this->statuses
= NULL;
362 $this->mapper
->refresh();
363 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
367 * Remove all database references to an extension.
369 * Add records of the extension to the database -- and enable it
372 * List of extension keys.
373 * @throws CRM_Extension_Exception
375 public function uninstall($keys) {
376 $origStatuses = $this->getStatuses();
378 // TODO: to mitigate the risk of crashing during installation, scan
379 // keys/statuses/types before doing anything
381 foreach ($keys as $key) {
382 switch ($origStatuses[$key]) {
383 case self
::STATUS_INSTALLED
:
384 case self
::STATUS_INSTALLED_MISSING
:
385 throw new CRM_Extension_Exception("Cannot uninstall extension; disable it first: $key");
387 case self
::STATUS_DISABLED
:
389 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
390 $typeManager->onPreUninstall($info);
391 $this->_removeExtensionEntry($info);
392 $typeManager->onPostUninstall($info);
395 case self
::STATUS_DISABLED_MISSING
:
397 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
398 $typeManager->onPreUninstall($info);
399 $this->_removeExtensionEntry($info);
400 $typeManager->onPostUninstall($info);
403 case self
::STATUS_UNINSTALLED
:
407 case self
::STATUS_UNKNOWN
:
409 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
413 $this->statuses
= NULL;
414 $this->mapper
->refresh();
415 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
419 * Determine the status of an extension.
424 * constant (STATUS_INSTALLED, STATUS_DISABLED, STATUS_UNINSTALLED, STATUS_UNKNOWN)
426 public function getStatus($key) {
427 $statuses = $this->getStatuses();
428 if (array_key_exists($key, $statuses)) {
429 return $statuses[$key];
432 return self
::STATUS_UNKNOWN
;
437 * Determine the status of all extensions.
440 * ($key => status_constant)
442 public function getStatuses() {
443 if (!is_array($this->statuses
)) {
444 $this->statuses
= [];
446 foreach ($this->fullContainer
->getKeys() as $key) {
447 $this->statuses
[$key] = self
::STATUS_UNINSTALLED
;
451 SELECT full_name, is_active
452 FROM civicrm_extension
454 $dao = CRM_Core_DAO
::executeQuery($sql);
455 while ($dao->fetch()) {
457 $path = $this->fullContainer
->getPath($dao->full_name
);
458 $codeExists = !empty($path) && is_dir($path);
460 catch (CRM_Extension_Exception
$e) {
463 if ($dao->is_active
) {
464 $this->statuses
[$dao->full_name
] = $codeExists ? self
::STATUS_INSTALLED
: self
::STATUS_INSTALLED_MISSING
;
467 $this->statuses
[$dao->full_name
] = $codeExists ? self
::STATUS_DISABLED
: self
::STATUS_DISABLED_MISSING
;
471 return $this->statuses
;
474 public function refresh() {
475 $this->statuses
= NULL;
476 // and, indirectly, defaultContainer
477 $this->fullContainer
->refresh();
478 $this->mapper
->refresh();
481 // ----------------------
484 * Find the $info and $typeManager for a $key
488 * @throws CRM_Extension_Exception
490 * (0 => CRM_Extension_Info, 1 => CRM_Extension_Manager_Interface)
492 private function _getInfoTypeHandler($key) {
494 $info = $this->mapper
->keyToInfo($key);
495 if (array_key_exists($info->type
, $this->typeManagers
)) {
496 return [$info, $this->typeManagers
[$info->type
]];
499 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type
);
504 * Find the $info and $typeManager for a $key
508 * @throws CRM_Extension_Exception
510 * (0 => CRM_Extension_Info, 1 => CRM_Extension_Manager_Interface)
512 private function _getMissingInfoTypeHandler($key) {
513 $info = $this->createInfoFromDB($key);
515 if (array_key_exists($info->type
, $this->typeManagers
)) {
516 return [$info, $this->typeManagers
[$info->type
]];
519 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type
);
523 throw new CRM_Extension_Exception("Failed to reconstruct missing extension: " . $key);
528 * @param CRM_Extension_Info $info
532 private function _createExtensionEntry(CRM_Extension_Info
$info) {
533 $dao = new CRM_Core_DAO_Extension();
534 $dao->label
= $info->label
;
535 $dao->name
= $info->name
;
536 $dao->full_name
= $info->key
;
537 $dao->type
= $info->type
;
538 $dao->file
= $info->file
;
540 return (bool) ($dao->insert());
544 * @param CRM_Extension_Info $info
548 private function _updateExtensionEntry(CRM_Extension_Info
$info) {
549 $dao = new CRM_Core_DAO_Extension();
550 $dao->full_name
= $info->key
;
551 if ($dao->find(TRUE)) {
552 $dao->label
= $info->label
;
553 $dao->name
= $info->name
;
554 $dao->full_name
= $info->key
;
555 $dao->type
= $info->type
;
556 $dao->file
= $info->file
;
558 return (bool) ($dao->update());
561 return $this->_createExtensionEntry($info);
566 * @param CRM_Extension_Info $info
568 * @throws CRM_Extension_Exception
570 private function _removeExtensionEntry(CRM_Extension_Info
$info) {
571 $dao = new CRM_Core_DAO_Extension();
572 $dao->full_name
= $info->key
;
573 if ($dao->find(TRUE)) {
574 if (CRM_Core_BAO_Extension
::del($dao->id
)) {
575 CRM_Core_Session
::setStatus(ts('Selected option value has been deleted.'), ts('Deleted'), 'success');
578 throw new CRM_Extension_Exception("Failed to remove extension entry");
580 } // else: post-condition already satisified
584 * @param CRM_Extension_Info $info
587 private function _setExtensionActive(CRM_Extension_Info
$info, $isActive) {
588 CRM_Core_DAO
::executeQuery('UPDATE civicrm_extension SET is_active = %1 where full_name = %2', [
589 1 => [$isActive, 'Integer'],
590 2 => [$info->key
, 'String'],
595 * Auto-generate a place-holder for a missing extension using info from
599 * @return CRM_Extension_Info|NULL
601 public function createInfoFromDB($key) {
602 $dao = new CRM_Core_DAO_Extension();
603 $dao->full_name
= $key;
604 if ($dao->find(TRUE)) {
605 $info = new CRM_Extension_Info($dao->full_name
, $dao->type
, $dao->name
, $dao->label
, $dao->file
);
614 * Build a list of extensions to install, in an order that will satisfy dependencies.
617 * List of extensions to install.
619 * List of extension keys, including dependencies, in order of installation.
621 public function findInstallRequirements($keys) {
622 $infos = $this->mapper
->getAllInfos();
623 // array(string $key).
624 $todoKeys = array_unique($keys);
625 // array(string $key => 1);
627 $sorter = new \MJS\TopSort\Implementations\
FixedArraySort();
629 while (!empty($todoKeys)) {
630 $key = array_shift($todoKeys);
631 if (isset($doneKeys[$key])) {
636 /** @var CRM_Extension_Info $info */
637 $info = @$infos[$key];
639 if ($this->getStatus($key) === self
::STATUS_INSTALLED
) {
640 $sorter->add($key, []);
642 elseif ($info && $info->requires
) {
643 $sorter->add($key, $info->requires
);
644 $todoKeys = array_merge($todoKeys, $info->requires
);
647 $sorter->add($key, []);
650 return $sorter->sort();
654 * Build a list of extensions to remove, in an order that will satisfy dependencies.
657 * List of extensions to install.
659 * List of extension keys, including dependencies, in order of removal.
661 public function findDisableRequirements($keys) {
663 self
::STATUS_INSTALLED
,
664 self
::STATUS_INSTALLED_MISSING
,
666 $installedInfos = $this->filterInfosByStatus($this->mapper
->getAllInfos(), $INSTALLED);
667 $revMap = CRM_Extension_Info
::buildReverseMap($installedInfos);
668 $todoKeys = array_unique($keys);
670 $sorter = new \MJS\TopSort\Implementations\
FixedArraySort();
672 while (!empty($todoKeys)) {
673 $key = array_shift($todoKeys);
674 if (isset($doneKeys[$key])) {
679 if (isset($revMap[$key])) {
680 $requiredBys = CRM_Utils_Array
::collect('key',
681 $this->filterInfosByStatus($revMap[$key], $INSTALLED));
682 $sorter->add($key, $requiredBys);
683 $todoKeys = array_merge($todoKeys, $requiredBys);
686 $sorter->add($key, []);
689 return $sorter->sort();
694 * @param $filterStatuses
697 protected function filterInfosByStatus($infos, $filterStatuses) {
699 foreach ($infos as $k => $v) {
700 if (in_array($this->getStatus($v->key
), $filterStatuses)) {