3 namespace wcf\system\package
;
5 use wcf\data\package\installation\queue\PackageInstallationQueueEditor
;
6 use wcf\data\package\installation\queue\PackageInstallationQueueList
;
7 use wcf\data\package\Package
;
8 use wcf\system\exception\SystemException
;
10 use wcf\util\FileUtil
;
11 use wcf\util\StringUtil
;
14 * Creates a logical node-based installation tree.
16 * @author Alexander Ebert
17 * @copyright 2001-2019 WoltLab GmbH
18 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
19 * @package WoltLabSuite\Core\System\Package
21 class PackageInstallationNodeBuilder
24 * true if current node is empty
27 public $emptyNode = true;
30 * active package installation dispatcher
31 * @var PackageInstallationDispatcher
36 * current installation node
42 * current parent installation node
45 public $parentNode = '';
48 * list of requirements to be checked before package installation
51 public $requirements = [];
54 * current sequence number within one node
57 public $sequenceNo = 0;
60 * list of packages about to be installed
63 protected static $pendingPackages = [];
66 * Creates a new instance of PackageInstallationNodeBuilder
68 * @param PackageInstallationDispatcher $installation
70 public function __construct(PackageInstallationDispatcher
$installation)
72 $this->installation
= $installation;
78 * @param string $parentNode
80 public function setParentNode($parentNode)
82 $this->parentNode
= $parentNode;
86 * Builds nodes for current installation queue.
88 public function buildNodes()
91 $this->buildRequirementNodes();
93 // register package version
94 self
::$pendingPackages[$this->installation
->getArchive()->getPackageInfo('name')] = $this->installation
->getArchive()->getPackageInfo('version');
96 // install package itself
97 if ($this->installation
->queue
->action
== 'install') {
98 $this->buildPackageNode();
101 // package installation plugins
102 $this->buildPluginNodes();
104 // optional packages (ignored on update)
105 if ($this->installation
->queue
->action
== 'install') {
106 $this->buildOptionalNodes();
109 if ($this->installation
->queue
->action
== 'update') {
110 $this->buildPackageNode();
114 $this->buildChildQueues();
118 * Returns the succeeding node.
120 * @param string $parentNode
123 public function getNextNode($parentNode = '')
126 FROM wcf" . WCF_N
. "_package_installation_node
129 $statement = WCF
::getDB()->prepareStatement($sql);
130 $statement->execute([
131 $this->installation
->queue
->processNo
,
134 $row = $statement->fetchArray();
144 * Returns package name associated with given queue id.
146 * @param int $queueID
149 public function getPackageNameByQueue($queueID)
151 $sql = "SELECT packageName
152 FROM wcf" . WCF_N
. "_package_installation_queue
154 $statement = WCF
::getDB()->prepareStatement($sql);
155 $statement->execute([$queueID]);
156 $row = $statement->fetchArray();
162 return $row['packageName'];
166 * Returns installation type by queue id.
168 * @param int $queueID
171 public function getInstallationTypeByQueue($queueID)
173 $sql = "SELECT action
174 FROM wcf" . WCF_N
. "_package_installation_queue
176 $statement = WCF
::getDB()->prepareStatement($sql);
177 $statement->execute([$queueID]);
178 $row = $statement->fetchArray();
180 return $row['action'];
184 * Returns data for current node.
186 * @param string $node
189 public function getNodeData($node)
191 $sql = "SELECT nodeType, nodeData, sequenceNo
192 FROM wcf" . WCF_N
. "_package_installation_node
195 ORDER BY sequenceNo ASC";
196 $statement = WCF
::getDB()->prepareStatement($sql);
197 $statement->execute([
198 $this->installation
->queue
->processNo
,
202 return $statement->fetchAll(\PDO
::FETCH_ASSOC
);
206 * Marks a node as completed.
208 * @param string $node
210 public function completeNode($node)
212 $sql = "UPDATE wcf" . WCF_N
. "_package_installation_node
216 $statement = WCF
::getDB()->prepareStatement($sql);
217 $statement->execute([
218 $this->installation
->queue
->processNo
,
224 * Removes all nodes associated with queue's process no.
226 * CAUTION: This method SHOULD NOT be called within the installation process!
228 public function purgeNodes()
230 $sql = "DELETE FROM wcf" . WCF_N
. "_package_installation_node
231 WHERE processNo = ?";
232 $statement = WCF
::getDB()->prepareStatement($sql);
233 $statement->execute([
234 $this->installation
->queue
->processNo
,
237 $sql = "DELETE FROM wcf" . WCF_N
. "_package_installation_form
239 $statement = WCF
::getDB()->prepareStatement($sql);
240 $statement->execute([
241 $this->installation
->queue
->queueID
,
246 * Calculates current setup process.
248 * @param string $node
251 public function calculateProgress($node)
259 FROM wcf" . WCF_N
. "_package_installation_node
260 WHERE processNo = ?";
261 $statement = WCF
::getDB()->prepareStatement($sql);
262 $statement->execute([
263 $this->installation
->queue
->processNo
,
265 while ($row = $statement->fetchArray()) {
269 $progress['outstanding']++
;
273 if (!$progress['done']) {
275 } elseif (!$progress['outstanding']) {
278 $total = $progress['done'] +
$progress['outstanding'];
280 return \round
(($progress['done'] / $total) * 100);
285 * Duplicates a node by re-inserting it and moving all descendants into a new tree.
287 * @param string $node
288 * @param int $sequenceNo
290 public function cloneNode($node, $sequenceNo)
292 $newNode = $this->getToken();
294 // update descendants
295 $sql = "UPDATE wcf" . WCF_N
. "_package_installation_node
299 $statement = WCF
::getDB()->prepareStatement($sql);
300 $statement->execute([
303 $this->installation
->queue
->processNo
,
306 // create a copy of current node (prevents empty nodes)
307 $sql = "SELECT nodeType, nodeData, done
308 FROM wcf" . WCF_N
. "_package_installation_node
312 $statement = WCF
::getDB()->prepareStatement($sql);
313 $statement->execute([
315 $this->installation
->queue
->processNo
,
318 $row = $statement->fetchArray();
320 $sql = "INSERT INTO wcf" . WCF_N
. "_package_installation_node
321 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData, done)
322 VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
323 $statement = WCF
::getDB()->prepareStatement($sql);
324 $statement->execute([
325 $this->installation
->queue
->queueID
,
326 $this->installation
->queue
->processNo
,
335 // move other child-nodes greater than $sequenceNo into new node
336 $sql = "UPDATE wcf" . WCF_N
. "_package_installation_node
339 sequenceNo = (sequenceNo - ?)
343 $statement = WCF
::getDB()->prepareStatement($sql);
344 $statement->execute([
349 $this->installation
->queue
->processNo
,
355 * Inserts a node before given target node. Will shift all target
356 * nodes to provide to be descendants of the new node. If you intend
357 * to insert more than a single node, you should prefer shiftNodes().
359 * @param string $beforeNode
360 * @param callable $callback
362 public function insertNode($beforeNode, callable
$callback)
364 $newNode = $this->getToken();
366 // update descendants
367 $sql = "UPDATE wcf" . WCF_N
. "_package_installation_node
371 $statement = WCF
::getDB()->prepareStatement($sql);
372 $statement->execute([
375 $this->installation
->queue
->processNo
,
379 $callback($beforeNode, $newNode);
383 * Shifts nodes to allow dynamic inserts at runtime.
385 * @param string $oldParentNode
386 * @param string $newParentNode
388 public function shiftNodes($oldParentNode, $newParentNode)
390 $sql = "UPDATE wcf" . WCF_N
. "_package_installation_node
394 $statement = WCF
::getDB()->prepareStatement($sql);
395 $statement->execute([
398 $this->installation
->queue
->processNo
,
403 * Builds package node used to install the package itself.
405 protected function buildPackageNode()
407 if (!empty($this->node
)) {
408 $this->parentNode
= $this->node
;
409 $this->sequenceNo
= 0;
412 $this->node
= $this->getToken();
414 $sql = "INSERT INTO wcf" . WCF_N
. "_package_installation_node
415 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
416 VALUES (?, ?, ?, ?, ?, ?, ?)";
417 $statement = WCF
::getDB()->prepareStatement($sql);
418 $statement->execute([
419 $this->installation
->queue
->queueID
,
420 $this->installation
->queue
->processNo
,
426 'package' => $this->installation
->getArchive()->getPackageInfo('name'),
427 'packageName' => $this->installation
->getArchive()->getLocalizedPackageInfo('packageName'),
428 'packageDescription' => $this->installation
->getArchive()->getLocalizedPackageInfo('packageDescription'),
429 'packageVersion' => $this->installation
->getArchive()->getPackageInfo('version'),
430 'packageDate' => $this->installation
->getArchive()->getPackageInfo('date'),
431 'packageURL' => $this->installation
->getArchive()->getPackageInfo('packageURL'),
432 'isApplication' => $this->installation
->getArchive()->getPackageInfo('isApplication'),
433 'author' => $this->installation
->getArchive()->getAuthorInfo('author'),
434 'authorURL' => $this->installation
->getArchive()->getAuthorInfo('authorURL') !== null ?
$this->installation
->getArchive()->getAuthorInfo('authorURL') : '',
435 'installDate' => TIME_NOW
,
436 'updateDate' => TIME_NOW
,
437 'requirements' => $this->requirements
,
438 'applicationDirectory' => $this->installation
->getArchive()->getPackageInfo('applicationDirectory') ?
: '',
444 * Builds nodes for required packages, whereas each has it own node.
447 * @throws SystemException
449 protected function buildRequirementNodes()
451 $queue = $this->installation
->queue
;
453 // handle requirements
454 $requiredPackages = $this->installation
->getArchive()->getOpenRequirements();
455 foreach ($requiredPackages as $packageName => $package) {
456 if (!isset($package['file'])) {
458 isset(self
::$pendingPackages[$packageName]) && (!isset($package['minversion']) || Package
::compareVersion(
459 self
::$pendingPackages[$packageName],
460 $package['minversion']
463 // the package will already be installed and no
464 // minversion is given or the package which will be
465 // installed satisfies the minversion, thus we can
466 // ignore this requirement
470 // requirements will be checked once package is about to be installed
471 $this->requirements
[$packageName] = [
472 'minVersion' => $package['minversion'] ??
'',
473 'packageID' => $package['packageID'],
479 if ($this->node
== '' && !empty($this->parentNode
)) {
480 $this->node
= $this->parentNode
;
484 $index = $this->installation
->getArchive()->getTar()->getIndexByFilename($package['file']);
485 if ($index === false) {
486 // workaround for WCFSetup
487 if (!PACKAGE_ID
&& $packageName == 'com.woltlab.wcf') {
491 throw new SystemException("Unable to find required package '" . $package['file'] . "' within archive of package '" . $this->installation
->queue
->package
. "'.");
494 $fileName = FileUtil
::getTemporaryFilename(
496 \
preg_replace('!^.*(?=\.(?:tar\.gz|tgz|tar)$)!i', '', \basename
($package['file']))
498 $this->installation
->getArchive()->getTar()->extract($index, $fileName);
501 $archive = new PackageArchive($fileName);
502 $archive->openArchive();
504 // check if delivered package has correct identifier
505 if ($archive->getPackageInfo('name') != $packageName) {
506 throw new SystemException("Invalid package file delivered for '" . $packageName . "' requirement of package '" . $this->installation
->getArchive()->getPackageInfo('name') . "' (delivered package: '" . $archive->getPackageInfo('name') . "').");
509 // check if delivered version satisfies minversion
511 isset($package['minversion']) && Package
::compareVersion(
512 $package['minversion'],
513 $archive->getPackageInfo('version')
516 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') . ".");
520 $sql = "SELECT packageID
521 FROM wcf" . WCF_N
. "_package
523 $statement = WCF
::getDB()->prepareStatement($sql);
524 $statement->execute([$archive->getPackageInfo('name')]);
525 $row = $statement->fetchArray();
526 $packageID = ($row === false) ?
null : $row['packageID'];
528 // check if package will already be installed
529 if (isset(self
::$pendingPackages[$packageName])) {
531 Package
::compareVersion(
532 self
::$pendingPackages[$packageName],
533 $archive->getPackageInfo('version')
536 // the version to be installed satisfies the required version
539 // the new delivered required version of the package has a
540 // higher version number, thus update/replace the existing
541 // package installation queue
545 if ($archive->getPackageInfo('name') === 'com.woltlab.wcf') {
546 WCF
::checkWritability();
550 $queue = PackageInstallationQueueEditor
::create([
551 'parentQueueID' => $queue->queueID
,
552 'processNo' => $queue->processNo
,
553 'userID' => WCF
::getUser()->userID
,
554 'package' => $archive->getPackageInfo('name'),
555 'packageID' => $packageID,
556 'packageName' => $archive->getLocalizedPackageInfo('packageName'),
557 'archive' => $fileName,
558 'action' => $packageID ?
'update' : 'install',
561 self
::$pendingPackages[$archive->getPackageInfo('name')] = $archive->getPackageInfo('version');
564 $installation = new PackageInstallationDispatcher($queue);
565 $installation->nodeBuilder
->setParentNode($this->node
);
566 $installation->nodeBuilder
->buildNodes();
567 $this->node
= $installation->nodeBuilder
->getCurrentNode();
572 * Returns current node
576 public function getCurrentNode()
582 * Builds package installation plugin nodes, whereas pips could be grouped within
583 * one node, differ from each by nothing but the sequence number.
587 protected function buildPluginNodes()
589 if (!empty($this->node
)) {
590 $this->parentNode
= $this->node
;
591 $this->sequenceNo
= 0;
594 $this->node
= $this->getToken();
598 $this->emptyNode
= true;
599 $instructions = ($this->installation
->getAction() == 'install') ?
$this->installation
->getArchive()->getInstallInstructions() : $this->installation
->getArchive()->getUpdateInstructions();
600 $count = \
count($instructions);
602 foreach ($instructions as $pip) {
605 if (isset($pip['attributes']['run']) && ($pip['attributes']['run'] == 'standalone')) {
606 // move into a new node unless current one is empty
607 if (!$this->emptyNode
) {
608 $this->parentNode
= $this->node
;
609 $this->node
= $this->getToken();
610 $this->sequenceNo
= 0;
614 'node' => $this->node
,
615 'parentNode' => $this->parentNode
,
616 'sequenceNo' => $this->sequenceNo
,
619 // create a new node for following PIPs, unless it is the last one
621 $this->parentNode
= $this->node
;
622 $this->node
= $this->getToken();
623 $this->sequenceNo
= 0;
625 $this->emptyNode
= true;
632 'node' => $this->node
,
633 'parentNode' => $this->parentNode
,
634 'sequenceNo' => $this->sequenceNo
,
637 $this->emptyNode
= false;
642 if (!empty($pluginNodes)) {
643 $sql = "INSERT INTO wcf" . WCF_N
. "_package_installation_node
644 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
645 VALUES (?, ?, ?, ?, ?, ?, ?)";
646 $statement = WCF
::getDB()->prepareStatement($sql);
648 foreach ($pluginNodes as $nodeData) {
649 $statement->execute([
650 $this->installation
->queue
->queueID
,
651 $this->installation
->queue
->processNo
,
652 $nodeData['sequenceNo'],
654 $nodeData['parentNode'],
656 \
serialize($nodeData['data']),
663 * Builds nodes for optional packages, whereas each package exists within
664 * one node with the same parent node, separated by sequence no (which does
665 * not really matter at this point).
667 protected function buildOptionalNodes()
671 $optionalPackages = $this->installation
->getArchive()->getOptionals();
672 foreach ($optionalPackages as $package) {
673 // check if already installed
674 if (Package
::isAlreadyInstalled($package['name'])) {
679 $index = $this->installation
->getArchive()->getTar()->getIndexByFilename($package['file']);
680 if ($index === false) {
681 throw new SystemException("Unable to find required package '" . $package['file'] . "' within archive.");
684 $fileName = FileUtil
::getTemporaryFilename(
686 \
preg_replace('!^.*(?=\.(?:tar\.gz|tgz|tar)$)!i', '', \basename
($package['file']))
688 $this->installation
->getArchive()->getTar()->extract($index, $fileName);
691 $archive = new PackageArchive($fileName);
692 $archive->openArchive();
694 // check if all requirements are met
695 $isInstallable = true;
696 foreach ($archive->getOpenRequirements() as $packageName => $requiredPackage) {
697 if (!isset($requiredPackage['file'])) {
698 // requirement is neither installed nor shipped, check if it is about to be installed
699 if (!isset(self
::$pendingPackages[$packageName])) {
700 $isInstallable = false;
706 // check for exclusions
707 $excludedPackages = $archive->getConflictedExcludedPackages();
708 if (!empty($excludedPackages)) {
709 $isInstallable = false;
712 $excludingPackages = $archive->getConflictedExcludingPackages();
713 if (!empty($excludingPackages)) {
714 $isInstallable = false;
718 'archive' => $fileName,
719 'isInstallable' => $isInstallable,
720 'package' => $archive->getPackageInfo('name'),
721 'packageName' => $archive->getLocalizedPackageInfo('packageName'),
722 'packageDescription' => $archive->getLocalizedPackageInfo('packageDescription'),
726 self
::$pendingPackages[$archive->getPackageInfo('name')] = $archive->getPackageInfo('version');
729 if (!empty($packages)) {
730 $this->parentNode
= $this->node
;
731 $this->node
= $this->getToken();
732 $this->sequenceNo
= 0;
734 $sql = "INSERT INTO wcf" . WCF_N
. "_package_installation_node
735 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
736 VALUES (?, ?, ?, ?, ?, ?, ?)";
737 $statement = WCF
::getDB()->prepareStatement($sql);
738 $statement->execute([
739 $this->installation
->queue
->queueID
,
740 $this->installation
->queue
->processNo
,
745 \
serialize($packages),
751 * Recursively build nodes for child queues.
753 protected function buildChildQueues()
755 $queueList = new PackageInstallationQueueList();
756 $queueList->getConditionBuilder()->add(
757 "package_installation_queue.parentQueueID = ?",
758 [$this->installation
->queue
->queueID
]
760 $queueList->getConditionBuilder()->add("package_installation_queue.queueID NOT IN (
762 FROM wcf" . WCF_N
. "_package_installation_node
764 $queueList->readObjects();
766 foreach ($queueList as $queue) {
767 $installation = new PackageInstallationDispatcher($queue);
769 // work-around for iterative package updates
770 if ($this->installation
->queue
->action
== 'update' && $queue->package
== $this->installation
->queue
->package
) {
771 $installation->setPreviousPackage([
772 'package' => $this->installation
->getArchive()->getPackageInfo('name'),
773 'packageVersion' => $this->installation
->getArchive()->getPackageInfo('version'),
777 $installation->nodeBuilder
->setParentNode($this->node
);
778 $installation->nodeBuilder
->buildNodes();
779 $this->node
= $installation->nodeBuilder
->getCurrentNode();
784 * Returns a short SHA1-hash.
788 protected function getToken()
790 return \
mb_substr(StringUtil
::getRandomID(), 0, 8);
794 * Returns queue id based upon current node.
796 * @param int $processNo
797 * @param string $node
800 public function getQueueByNode($processNo, $node)
802 $sql = "SELECT queueID
803 FROM wcf" . WCF_N
. "_package_installation_node
806 $statement = WCF
::getDB()->prepareStatement($sql);
807 $statement->execute([
811 $row = $statement->fetchArray();
813 if ($row === false) {
814 // PHP <7.4 _silently_ returns `null` when attempting to read an array index
815 // when the source value equals `false`.
819 return $row['queueID'];