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-2019 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
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 $sql = "INSERT INTO wcf".WCF_N
."_package_installation_node
400 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
401 VALUES (?, ?, ?, ?, ?, ?, ?)";
402 $statement = WCF
::getDB()->prepareStatement($sql);
403 $statement->execute([
404 $this->installation
->queue
->queueID
,
405 $this->installation
->queue
->processNo
,
411 'package' => $this->installation
->getArchive()->getPackageInfo('name'),
412 'packageName' => $this->installation
->getArchive()->getLocalizedPackageInfo('packageName'),
413 'packageDescription' => $this->installation
->getArchive()->getLocalizedPackageInfo('packageDescription'),
414 'packageVersion' => $this->installation
->getArchive()->getPackageInfo('version'),
415 'packageDate' => $this->installation
->getArchive()->getPackageInfo('date'),
416 'packageURL' => $this->installation
->getArchive()->getPackageInfo('packageURL'),
417 'isApplication' => $this->installation
->getArchive()->getPackageInfo('isApplication'),
418 'author' => $this->installation
->getArchive()->getAuthorInfo('author'),
419 'authorURL' => $this->installation
->getArchive()->getAuthorInfo('authorURL') !== null ?
$this->installation
->getArchive()->getAuthorInfo('authorURL') : '',
420 'installDate' => TIME_NOW
,
421 'updateDate' => TIME_NOW
,
422 'requirements' => $this->requirements
,
423 'applicationDirectory' => $this->installation
->getArchive()->getPackageInfo('applicationDirectory') ?
: '',
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
512 if ($archive->getPackageInfo('name') === 'com.woltlab.wcf') {
513 WCF
::checkWritability();
517 $queue = PackageInstallationQueueEditor
::create([
518 'parentQueueID' => $queue->queueID
,
519 'processNo' => $queue->processNo
,
520 'userID' => WCF
::getUser()->userID
,
521 'package' => $archive->getPackageInfo('name'),
522 'packageID' => $packageID,
523 'packageName' => $archive->getLocalizedPackageInfo('packageName'),
524 'archive' => $fileName,
525 'action' => $packageID ?
'update' : 'install'
528 self
::$pendingPackages[$archive->getPackageInfo('name')] = $archive->getPackageInfo('version');
531 $installation = new PackageInstallationDispatcher($queue);
532 $installation->nodeBuilder
->setParentNode($this->node
);
533 $installation->nodeBuilder
->buildNodes();
534 $this->node
= $installation->nodeBuilder
->getCurrentNode();
539 * Returns current node
543 public function getCurrentNode() {
548 * Builds package installation plugin nodes, whereas pips could be grouped within
549 * one node, differ from each by nothing but the sequence number.
553 protected function buildPluginNodes() {
554 if (!empty($this->node
)) {
555 $this->parentNode
= $this->node
;
556 $this->sequenceNo
= 0;
559 $this->node
= $this->getToken();
563 $this->emptyNode
= true;
564 $instructions = ($this->installation
->getAction() == 'install') ?
$this->installation
->getArchive()->getInstallInstructions() : $this->installation
->getArchive()->getUpdateInstructions();
565 $count = count($instructions);
567 foreach ($instructions as $pip) {
570 if (isset($pip['attributes']['run']) && ($pip['attributes']['run'] == 'standalone')) {
571 // move into a new node unless current one is empty
572 if (!$this->emptyNode
) {
573 $this->parentNode
= $this->node
;
574 $this->node
= $this->getToken();
575 $this->sequenceNo
= 0;
579 'node' => $this->node
,
580 'parentNode' => $this->parentNode
,
581 'sequenceNo' => $this->sequenceNo
584 // create a new node for following PIPs, unless it is the last one
586 $this->parentNode
= $this->node
;
587 $this->node
= $this->getToken();
588 $this->sequenceNo
= 0;
590 $this->emptyNode
= true;
598 'node' => $this->node
,
599 'parentNode' => $this->parentNode
,
600 'sequenceNo' => $this->sequenceNo
603 $this->emptyNode
= false;
608 if (!empty($pluginNodes)) {
609 $sql = "INSERT INTO wcf".WCF_N
."_package_installation_node
610 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
611 VALUES (?, ?, ?, ?, ?, ?, ?)";
612 $statement = WCF
::getDB()->prepareStatement($sql);
614 foreach ($pluginNodes as $index => $nodeData) {
615 $statement->execute([
616 $this->installation
->queue
->queueID
,
617 $this->installation
->queue
->processNo
,
618 $nodeData['sequenceNo'],
620 $nodeData['parentNode'],
622 serialize($nodeData['data'])
629 * Builds nodes for optional packages, whereas each package exists within
630 * one node with the same parent node, separated by sequence no (which does
631 * not really matter at this point).
633 protected function buildOptionalNodes() {
636 $optionalPackages = $this->installation
->getArchive()->getOptionals();
637 foreach ($optionalPackages as $package) {
638 // check if already installed
639 if (Package
::isAlreadyInstalled($package['name'])) {
644 $index = $this->installation
->getArchive()->getTar()->getIndexByFilename($package['file']);
645 if ($index === false) {
646 throw new SystemException("Unable to find required package '".$package['file']."' within archive.");
649 $fileName = FileUtil
::getTemporaryFilename('package_', preg_replace('!^.*(?=\.(?:tar\.gz|tgz|tar)$)!i', '', basename($package['file'])));
650 $this->installation
->getArchive()->getTar()->extract($index, $fileName);
653 $archive = new PackageArchive($fileName);
654 $archive->openArchive();
656 // check if all requirements are met
657 $isInstallable = true;
658 foreach ($archive->getOpenRequirements() as $packageName => $requiredPackage) {
659 if (!isset($requiredPackage['file'])) {
660 // requirement is neither installed nor shipped, check if it is about to be installed
661 if (!isset(self
::$pendingPackages[$packageName])) {
662 $isInstallable = false;
668 // check for exclusions
669 $excludedPackages = $archive->getConflictedExcludedPackages();
670 if (!empty($excludedPackages)) {
671 $isInstallable = false;
674 $excludingPackages = $archive->getConflictedExcludingPackages();
675 if (!empty($excludingPackages)) {
676 $isInstallable = false;
680 'archive' => $fileName,
681 'isInstallable' => $isInstallable,
682 'package' => $archive->getPackageInfo('name'),
683 'packageName' => $archive->getLocalizedPackageInfo('packageName'),
684 'packageDescription' => $archive->getLocalizedPackageInfo('packageDescription'),
688 self
::$pendingPackages[$archive->getPackageInfo('name')] = $archive->getPackageInfo('version');
691 if (!empty($packages)) {
692 $this->parentNode
= $this->node
;
693 $this->node
= $this->getToken();
694 $this->sequenceNo
= 0;
696 $sql = "INSERT INTO wcf".WCF_N
."_package_installation_node
697 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
698 VALUES (?, ?, ?, ?, ?, ?, ?)";
699 $statement = WCF
::getDB()->prepareStatement($sql);
700 $statement->execute([
701 $this->installation
->queue
->queueID
,
702 $this->installation
->queue
->processNo
,
713 * Recursively build nodes for child queues.
715 protected function buildChildQueues() {
716 $queueList = new PackageInstallationQueueList();
717 $queueList->getConditionBuilder()->add("package_installation_queue.parentQueueID = ?", [$this->installation
->queue
->queueID
]);
718 $queueList->getConditionBuilder()->add("package_installation_queue.queueID NOT IN (SELECT queueID FROM wcf".WCF_N
."_package_installation_node)");
719 $queueList->readObjects();
721 foreach ($queueList as $queue) {
722 $installation = new PackageInstallationDispatcher($queue);
724 // work-around for iterative package updates
725 if ($this->installation
->queue
->action
== 'update' && $queue->package
== $this->installation
->queue
->package
) {
726 $installation->setPreviousPackage([
727 'package' => $this->installation
->getArchive()->getPackageInfo('name'),
728 'packageVersion' => $this->installation
->getArchive()->getPackageInfo('version')
732 $installation->nodeBuilder
->setParentNode($this->node
);
733 $installation->nodeBuilder
->buildNodes();
734 $this->node
= $installation->nodeBuilder
->getCurrentNode();
739 * Returns a short SHA1-hash.
743 protected function getToken() {
744 return mb_substr(StringUtil
::getRandomID(), 0, 8);
748 * Returns queue id based upon current node.
750 * @param integer $processNo
751 * @param string $node
752 * @return integer|null
754 public function getQueueByNode($processNo, $node) {
755 $sql = "SELECT queueID
756 FROM wcf".WCF_N
."_package_installation_node
759 $statement = WCF
::getDB()->prepareStatement($sql);
760 $statement->execute([
764 $row = $statement->fetchArray();
766 if ($row === false) {
767 // PHP <7.4 _silently_ returns `null` when attempting to read an array index
768 // when the source value equals `false`.
772 return $row['queueID'];