2 namespace wcf\system\package
;
3 use wcf\data\package\installation\queue\PackageInstallationQueueEditor
;
4 use wcf\data\package\installation\queue\PackageInstallationQueueList
;
5 use wcf\data\package\Package
;
6 use wcf\system\exception\SystemException
;
9 use wcf\util\StringUtil
;
12 * Creates a logical node-based installation tree.
14 * @author Alexander Ebert
15 * @copyright 2001-2018 WoltLab GmbH
16 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
17 * @package WoltLabSuite\Core\System\Package
19 class PackageInstallationNodeBuilder
{
21 * true if current node is empty
24 public $emptyNode = true;
27 * active package installation dispatcher
28 * @var PackageInstallationDispatcher
30 public $installation = null;
33 * current installation node
39 * current parent installation node
42 public $parentNode = '';
45 * list of requirements to be checked before package installation
48 public $requirements = [];
51 * current sequence number within one node
54 public $sequenceNo = 0;
57 * list of packages about to be installed
60 protected static $pendingPackages = [];
63 * Creates a new instance of PackageInstallationNodeBuilder
65 * @param PackageInstallationDispatcher $installation
67 public function __construct(PackageInstallationDispatcher
$installation) {
68 $this->installation
= $installation;
74 * @param string $parentNode
76 public function setParentNode($parentNode) {
77 $this->parentNode
= $parentNode;
81 * Builds nodes for current installation queue.
83 public function buildNodes() {
85 $this->buildRequirementNodes();
87 // register package version
88 self
::$pendingPackages[$this->installation
->getArchive()->getPackageInfo('name')] = $this->installation
->getArchive()->getPackageInfo('version');
90 // install package itself
91 if ($this->installation
->queue
->action
== 'install') {
92 $this->buildPackageNode();
95 // package installation plugins
96 $this->buildPluginNodes();
98 // optional packages (ignored on update)
99 if ($this->installation
->queue
->action
== 'install') {
100 $this->buildOptionalNodes();
103 if ($this->installation
->queue
->action
== 'update') {
104 $this->buildPackageNode();
108 $this->buildChildQueues();
112 * Returns the succeeding node.
114 * @param string $parentNode
117 public function getNextNode($parentNode = '') {
119 FROM wcf".WCF_N
."_package_installation_node
122 $statement = WCF
::getDB()->prepareStatement($sql);
123 $statement->execute([
124 $this->installation
->queue
->processNo
,
127 $row = $statement->fetchArray();
137 * Returns package name associated with given queue id.
139 * @param integer $queueID
142 public function getPackageNameByQueue($queueID) {
143 $sql = "SELECT packageName
144 FROM wcf".WCF_N
."_package_installation_queue
146 $statement = WCF
::getDB()->prepareStatement($sql);
147 $statement->execute([$queueID]);
148 $row = $statement->fetchArray();
154 return $row['packageName'];
158 * Returns installation type by queue id.
160 * @param integer $queueID
163 public function getInstallationTypeByQueue($queueID) {
164 $sql = "SELECT action
165 FROM wcf".WCF_N
."_package_installation_queue
167 $statement = WCF
::getDB()->prepareStatement($sql);
168 $statement->execute([$queueID]);
169 $row = $statement->fetchArray();
171 return $row['action'];
175 * Returns data for current node.
177 * @param string $node
180 public function getNodeData($node) {
181 $sql = "SELECT nodeType, nodeData, sequenceNo
182 FROM wcf".WCF_N
."_package_installation_node
185 ORDER BY sequenceNo ASC";
186 $statement = WCF
::getDB()->prepareStatement($sql);
187 $statement->execute([
188 $this->installation
->queue
->processNo
,
192 return $statement->fetchAll(\PDO
::FETCH_ASSOC
);
196 * Marks a node as completed.
198 * @param string $node
200 public function completeNode($node) {
201 $sql = "UPDATE wcf".WCF_N
."_package_installation_node
205 $statement = WCF
::getDB()->prepareStatement($sql);
206 $statement->execute([
207 $this->installation
->queue
->processNo
,
213 * Removes all nodes associated with queue's process no.
215 * CAUTION: This method SHOULD NOT be called within the installation process!
217 public function purgeNodes() {
218 $sql = "DELETE FROM wcf".WCF_N
."_package_installation_node
219 WHERE processNo = ?";
220 $statement = WCF
::getDB()->prepareStatement($sql);
221 $statement->execute([
222 $this->installation
->queue
->processNo
225 $sql = "DELETE FROM wcf".WCF_N
."_package_installation_form
227 $statement = WCF
::getDB()->prepareStatement($sql);
228 $statement->execute([
229 $this->installation
->queue
->queueID
234 * Calculates current setup process.
236 * @param string $node
239 public function calculateProgress($node) {
246 FROM wcf".WCF_N
."_package_installation_node
247 WHERE processNo = ?";
248 $statement = WCF
::getDB()->prepareStatement($sql);
249 $statement->execute([
250 $this->installation
->queue
->processNo
252 while ($row = $statement->fetchArray()) {
257 $progress['outstanding']++
;
261 if (!$progress['done']) {
264 else if (!$progress['outstanding']) {
268 $total = $progress['done'] +
$progress['outstanding'];
269 return round(($progress['done'] / $total) * 100);
274 * Duplicates a node by re-inserting it and moving all descendants into a new tree.
276 * @param string $node
277 * @param integer $sequenceNo
279 public function cloneNode($node, $sequenceNo) {
280 $newNode = $this->getToken();
282 // update descendants
283 $sql = "UPDATE wcf".WCF_N
."_package_installation_node
287 $statement = WCF
::getDB()->prepareStatement($sql);
288 $statement->execute([
291 $this->installation
->queue
->processNo
294 // create a copy of current node (prevents empty nodes)
295 $sql = "SELECT nodeType, nodeData, done
296 FROM wcf".WCF_N
."_package_installation_node
300 $statement = WCF
::getDB()->prepareStatement($sql);
301 $statement->execute([
303 $this->installation
->queue
->processNo
,
306 $row = $statement->fetchArray();
308 $sql = "INSERT INTO wcf".WCF_N
."_package_installation_node
309 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData, done)
310 VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
311 $statement = WCF
::getDB()->prepareStatement($sql);
312 $statement->execute([
313 $this->installation
->queue
->queueID
,
314 $this->installation
->queue
->processNo
,
323 // move other child-nodes greater than $sequenceNo into new node
324 $sql = "UPDATE wcf".WCF_N
."_package_installation_node
327 sequenceNo = (sequenceNo - ?)
331 $statement = WCF
::getDB()->prepareStatement($sql);
332 $statement->execute([
337 $this->installation
->queue
->processNo
,
343 * Inserts a node before given target node. Will shift all target
344 * nodes to provide to be descendants of the new node. If you intend
345 * to insert more than a single node, you should prefer shiftNodes().
347 * @param string $beforeNode
348 * @param callable $callback
350 public function insertNode($beforeNode, callable
$callback) {
351 $newNode = $this->getToken();
353 // update descendants
354 $sql = "UPDATE wcf".WCF_N
."_package_installation_node
358 $statement = WCF
::getDB()->prepareStatement($sql);
359 $statement->execute([
362 $this->installation
->queue
->processNo
366 $callback($beforeNode, $newNode);
370 * Shifts nodes to allow dynamic inserts at runtime.
372 * @param string $oldParentNode
373 * @param string $newParentNode
375 public function shiftNodes($oldParentNode, $newParentNode) {
376 $sql = "UPDATE wcf".WCF_N
."_package_installation_node
380 $statement = WCF
::getDB()->prepareStatement($sql);
381 $statement->execute([
384 $this->installation
->queue
->processNo
389 * Builds package node used to install the package itself.
391 protected function buildPackageNode() {
392 if (!empty($this->node
)) {
393 $this->parentNode
= $this->node
;
394 $this->sequenceNo
= 0;
397 $this->node
= $this->getToken();
399 // calculate the number of instances of this package
400 $sql = "INSERT INTO wcf".WCF_N
."_package_installation_node
401 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
402 VALUES (?, ?, ?, ?, ?, ?, ?)";
403 $statement = WCF
::getDB()->prepareStatement($sql);
404 $statement->execute([
405 $this->installation
->queue
->queueID
,
406 $this->installation
->queue
->processNo
,
412 'package' => $this->installation
->getArchive()->getPackageInfo('name'),
413 'packageName' => $this->installation
->getArchive()->getLocalizedPackageInfo('packageName'),
414 'packageDescription' => $this->installation
->getArchive()->getLocalizedPackageInfo('packageDescription'),
415 'packageVersion' => $this->installation
->getArchive()->getPackageInfo('version'),
416 'packageDate' => $this->installation
->getArchive()->getPackageInfo('date'),
417 'packageURL' => $this->installation
->getArchive()->getPackageInfo('packageURL'),
418 'isApplication' => $this->installation
->getArchive()->getPackageInfo('isApplication'),
419 'author' => $this->installation
->getArchive()->getAuthorInfo('author'),
420 'authorURL' => $this->installation
->getArchive()->getAuthorInfo('authorURL') !== null ?
$this->installation
->getArchive()->getAuthorInfo('authorURL') : '',
421 'installDate' => TIME_NOW
,
422 'updateDate' => TIME_NOW
,
423 'requirements' => $this->requirements
429 * Builds nodes for required packages, whereas each has it own node.
432 * @throws SystemException
434 protected function buildRequirementNodes() {
435 $queue = $this->installation
->queue
;
437 // handle requirements
438 $requiredPackages = $this->installation
->getArchive()->getOpenRequirements();
439 foreach ($requiredPackages as $packageName => $package) {
440 if (!isset($package['file'])) {
441 if (isset(self
::$pendingPackages[$packageName]) && (!isset($package['minversion']) || Package
::compareVersion(self
::$pendingPackages[$packageName], $package['minversion']) >= 0)) {
442 // the package will already be installed and no
443 // minversion is given or the package which will be
444 // installed satisfies the minversion, thus we can
445 // ignore this requirement
449 // requirements will be checked once package is about to be installed
450 $this->requirements
[$packageName] = [
451 'minVersion' => isset($package['minversion']) ?
$package['minversion'] : '',
452 'packageID' => $package['packageID']
458 if ($this->node
== '' && !empty($this->parentNode
)) {
459 $this->node
= $this->parentNode
;
463 $index = $this->installation
->getArchive()->getTar()->getIndexByFilename($package['file']);
464 if ($index === false) {
465 // workaround for WCFSetup
466 if (!PACKAGE_ID
&& $packageName == 'com.woltlab.wcf') {
470 throw new SystemException("Unable to find required package '".$package['file']."' within archive of package '".$this->installation
->queue
->package
."'.");
473 $fileName = FileUtil
::getTemporaryFilename('package_', preg_replace('!^.*(?=\.(?:tar\.gz|tgz|tar)$)!i', '', basename($package['file'])));
474 $this->installation
->getArchive()->getTar()->extract($index, $fileName);
477 $archive = new PackageArchive($fileName);
478 $archive->openArchive();
480 // check if delivered package has correct identifier
481 if ($archive->getPackageInfo('name') != $packageName) {
482 throw new SystemException("Invalid package file delivered for '".$packageName."' requirement of package '".$this->installation
->getArchive()->getPackageInfo('name')."' (delivered package: '".$archive->getPackageInfo('name')."').");
485 // check if delivered version satisfies minversion
486 if (isset($package['minversion']) && Package
::compareVersion($package['minversion'], $archive->getPackageInfo('version')) > 0) {
487 throw new SystemException("Package '".$this->installation
->getArchive()->getPackageInfo('name')."' requires package '".$packageName."' at least in version ".$package['minversion'].", but only delivers version ".$archive->getPackageInfo('version').".");
491 $sql = "SELECT packageID
492 FROM wcf".WCF_N
."_package
494 $statement = WCF
::getDB()->prepareStatement($sql);
495 $statement->execute([$archive->getPackageInfo('name')]);
496 $row = $statement->fetchArray();
497 $packageID = ($row === false) ?
null : $row['packageID'];
499 // check if package will already be installed
500 if (isset(self
::$pendingPackages[$packageName])) {
501 if (Package
::compareVersion(self
::$pendingPackages[$packageName], $archive->getPackageInfo('version')) >= 0) {
502 // the version to be installed satisfies the required version
506 // the new delivered required version of the package has a
507 // higher version number, thus update/replace the existing
508 // package installation queue
513 $queue = PackageInstallationQueueEditor
::create([
514 'parentQueueID' => $queue->queueID
,
515 'processNo' => $queue->processNo
,
516 'userID' => WCF
::getUser()->userID
,
517 'package' => $archive->getPackageInfo('name'),
518 'packageID' => $packageID,
519 'packageName' => $archive->getLocalizedPackageInfo('packageName'),
520 'archive' => $fileName,
521 'action' => $packageID ?
'update' : 'install'
524 self
::$pendingPackages[$archive->getPackageInfo('name')] = $archive->getPackageInfo('version');
527 $installation = new PackageInstallationDispatcher($queue);
528 $installation->nodeBuilder
->setParentNode($this->node
);
529 $installation->nodeBuilder
->buildNodes();
530 $this->node
= $installation->nodeBuilder
->getCurrentNode();
535 * Returns current node
539 public function getCurrentNode() {
544 * Builds package installation plugin nodes, whereas pips could be grouped within
545 * one node, differ from each by nothing but the sequence number.
549 protected function buildPluginNodes() {
550 if (!empty($this->node
)) {
551 $this->parentNode
= $this->node
;
552 $this->sequenceNo
= 0;
555 $this->node
= $this->getToken();
559 $this->emptyNode
= true;
560 $instructions = ($this->installation
->getAction() == 'install') ?
$this->installation
->getArchive()->getInstallInstructions() : $this->installation
->getArchive()->getUpdateInstructions();
561 $count = count($instructions);
563 foreach ($instructions as $pip) {
566 if (isset($pip['attributes']['run']) && ($pip['attributes']['run'] == 'standalone')) {
567 // move into a new node unless current one is empty
568 if (!$this->emptyNode
) {
569 $this->parentNode
= $this->node
;
570 $this->node
= $this->getToken();
571 $this->sequenceNo
= 0;
575 'node' => $this->node
,
576 'parentNode' => $this->parentNode
,
577 'sequenceNo' => $this->sequenceNo
580 // create a new node for following PIPs, unless it is the last one
582 $this->parentNode
= $this->node
;
583 $this->node
= $this->getToken();
584 $this->sequenceNo
= 0;
586 $this->emptyNode
= true;
594 'node' => $this->node
,
595 'parentNode' => $this->parentNode
,
596 'sequenceNo' => $this->sequenceNo
599 $this->emptyNode
= false;
604 if (!empty($pluginNodes)) {
605 $sql = "INSERT INTO wcf".WCF_N
."_package_installation_node
606 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
607 VALUES (?, ?, ?, ?, ?, ?, ?)";
608 $statement = WCF
::getDB()->prepareStatement($sql);
610 foreach ($pluginNodes as $index => $nodeData) {
611 $statement->execute([
612 $this->installation
->queue
->queueID
,
613 $this->installation
->queue
->processNo
,
614 $nodeData['sequenceNo'],
616 $nodeData['parentNode'],
618 serialize($nodeData['data'])
625 * Builds nodes for optional packages, whereas each package exists within
626 * one node with the same parent node, separated by sequence no (which does
627 * not really matter at this point).
629 protected function buildOptionalNodes() {
632 $optionalPackages = $this->installation
->getArchive()->getOptionals();
633 foreach ($optionalPackages as $package) {
634 // check if already installed
635 if (Package
::isAlreadyInstalled($package['name'])) {
640 $index = $this->installation
->getArchive()->getTar()->getIndexByFilename($package['file']);
641 if ($index === false) {
642 throw new SystemException("Unable to find required package '".$package['file']."' within archive.");
645 $fileName = FileUtil
::getTemporaryFilename('package_', preg_replace('!^.*(?=\.(?:tar\.gz|tgz|tar)$)!i', '', basename($package['file'])));
646 $this->installation
->getArchive()->getTar()->extract($index, $fileName);
649 $archive = new PackageArchive($fileName);
650 $archive->openArchive();
652 // check if all requirements are met
653 $isInstallable = true;
654 foreach ($archive->getOpenRequirements() as $packageName => $requiredPackage) {
655 if (!isset($requiredPackage['file'])) {
656 // requirement is neither installed nor shipped, check if it is about to be installed
657 if (!isset(self
::$pendingPackages[$packageName])) {
658 $isInstallable = false;
664 // check for exclusions
665 $excludedPackages = $archive->getConflictedExcludedPackages();
666 if (!empty($excludedPackages)) {
667 $isInstallable = false;
670 $excludingPackages = $archive->getConflictedExcludingPackages();
671 if (!empty($excludingPackages)) {
672 $isInstallable = false;
676 'archive' => $fileName,
677 'isInstallable' => $isInstallable,
678 'package' => $archive->getPackageInfo('name'),
679 'packageName' => $archive->getLocalizedPackageInfo('packageName'),
680 'packageDescription' => $archive->getLocalizedPackageInfo('packageDescription'),
684 self
::$pendingPackages[$archive->getPackageInfo('name')] = $archive->getPackageInfo('version');
687 if (!empty($packages)) {
688 $this->parentNode
= $this->node
;
689 $this->node
= $this->getToken();
690 $this->sequenceNo
= 0;
692 $sql = "INSERT INTO wcf".WCF_N
."_package_installation_node
693 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
694 VALUES (?, ?, ?, ?, ?, ?, ?)";
695 $statement = WCF
::getDB()->prepareStatement($sql);
696 $statement->execute([
697 $this->installation
->queue
->queueID
,
698 $this->installation
->queue
->processNo
,
709 * Recursively build nodes for child queues.
711 protected function buildChildQueues() {
712 $queueList = new PackageInstallationQueueList();
713 $queueList->getConditionBuilder()->add("package_installation_queue.parentQueueID = ?", [$this->installation
->queue
->queueID
]);
714 $queueList->getConditionBuilder()->add("package_installation_queue.queueID NOT IN (SELECT queueID FROM wcf".WCF_N
."_package_installation_node)");
715 $queueList->readObjects();
717 foreach ($queueList as $queue) {
718 $installation = new PackageInstallationDispatcher($queue);
720 // work-around for iterative package updates
721 if ($this->installation
->queue
->action
== 'update' && $queue->package
== $this->installation
->queue
->package
) {
722 $installation->setPreviousPackage([
723 'package' => $this->installation
->getArchive()->getPackageInfo('name'),
724 'packageVersion' => $this->installation
->getArchive()->getPackageInfo('version')
728 $installation->nodeBuilder
->setParentNode($this->node
);
729 $installation->nodeBuilder
->buildNodes();
730 $this->node
= $installation->nodeBuilder
->getCurrentNode();
735 * Returns a short SHA1-hash.
739 protected function getToken() {
740 return mb_substr(StringUtil
::getRandomID(), 0, 8);
744 * Returns queue id based upon current node.
746 * @param integer $processNo
747 * @param string $node
748 * @return integer|null
750 public function getQueueByNode($processNo, $node) {
751 $sql = "SELECT queueID
752 FROM wcf".WCF_N
."_package_installation_node
755 $statement = WCF
::getDB()->prepareStatement($sql);
756 $statement->execute([
760 $row = $statement->fetchArray();
762 if ($row === false) {
763 // PHP <7.4 _silently_ returns `null` when attempting to read an array index
764 // when the source value equals `false`.
768 return $row['queueID'];