2 declare(strict_types
=1);
3 namespace wcf\system\package
;
4 use wcf\data\package\installation\queue\PackageInstallationQueueEditor
;
5 use wcf\data\package\installation\queue\PackageInstallationQueueList
;
6 use wcf\data\package\Package
;
7 use wcf\system\exception\SystemException
;
10 use wcf\util\StringUtil
;
13 * Creates a logical node-based installation tree.
15 * @author Alexander Ebert
16 * @copyright 2001-2018 WoltLab GmbH
17 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
18 * @package WoltLabSuite\Core\System\Package
20 class PackageInstallationNodeBuilder
{
22 * true if current node is empty
25 public $emptyNode = true;
28 * active package installation dispatcher
29 * @var PackageInstallationDispatcher
31 public $installation = null;
34 * current installation node
40 * current parent installation node
43 public $parentNode = '';
46 * list of requirements to be checked before package installation
49 public $requirements = [];
52 * current sequence number within one node
55 public $sequenceNo = 0;
58 * list of packages about to be installed
61 protected static $pendingPackages = [];
64 * Creates a new instance of PackageInstallationNodeBuilder
66 * @param PackageInstallationDispatcher $installation
68 public function __construct(PackageInstallationDispatcher
$installation) {
69 $this->installation
= $installation;
75 * @param string $parentNode
77 public function setParentNode($parentNode) {
78 $this->parentNode
= $parentNode;
82 * Builds nodes for current installation queue.
84 public function buildNodes() {
86 $this->buildRequirementNodes();
88 // register package version
89 self
::$pendingPackages[$this->installation
->getArchive()->getPackageInfo('name')] = $this->installation
->getArchive()->getPackageInfo('version');
91 // install package itself
92 if ($this->installation
->queue
->action
== 'install') {
93 $this->buildPackageNode();
96 // package installation plugins
97 $this->buildPluginNodes();
99 // optional packages (ignored on update)
100 if ($this->installation
->queue
->action
== 'install') {
101 $this->buildOptionalNodes();
104 if ($this->installation
->queue
->action
== 'update') {
105 $this->buildPackageNode();
109 $this->buildChildQueues();
113 * Returns the succeeding node.
115 * @param string $parentNode
118 public function getNextNode($parentNode = '') {
120 FROM wcf".WCF_N
."_package_installation_node
123 $statement = WCF
::getDB()->prepareStatement($sql);
124 $statement->execute([
125 $this->installation
->queue
->processNo
,
128 $row = $statement->fetchArray();
138 * Returns package name associated with given queue id.
140 * @param integer $queueID
143 public function getPackageNameByQueue($queueID) {
144 $sql = "SELECT packageName
145 FROM wcf".WCF_N
."_package_installation_queue
147 $statement = WCF
::getDB()->prepareStatement($sql);
148 $statement->execute([$queueID]);
149 $row = $statement->fetchArray();
155 return $row['packageName'];
159 * Returns installation type by queue id.
161 * @param integer $queueID
164 public function getInstallationTypeByQueue($queueID) {
165 $sql = "SELECT action
166 FROM wcf".WCF_N
."_package_installation_queue
168 $statement = WCF
::getDB()->prepareStatement($sql);
169 $statement->execute([$queueID]);
170 $row = $statement->fetchArray();
172 return $row['action'];
176 * Returns data for current node.
178 * @param string $node
181 public function getNodeData($node) {
182 $sql = "SELECT nodeType, nodeData, sequenceNo
183 FROM wcf".WCF_N
."_package_installation_node
186 ORDER BY sequenceNo ASC";
187 $statement = WCF
::getDB()->prepareStatement($sql);
188 $statement->execute([
189 $this->installation
->queue
->processNo
,
193 return $statement->fetchAll(\PDO
::FETCH_ASSOC
);
197 * Marks a node as completed.
199 * @param string $node
201 public function completeNode($node) {
202 $sql = "UPDATE wcf".WCF_N
."_package_installation_node
206 $statement = WCF
::getDB()->prepareStatement($sql);
207 $statement->execute([
208 $this->installation
->queue
->processNo
,
214 * Removes all nodes associated with queue's process no.
216 * CAUTION: This method SHOULD NOT be called within the installation process!
218 public function purgeNodes() {
219 $sql = "DELETE FROM wcf".WCF_N
."_package_installation_node
220 WHERE processNo = ?";
221 $statement = WCF
::getDB()->prepareStatement($sql);
222 $statement->execute([
223 $this->installation
->queue
->processNo
226 $sql = "DELETE FROM wcf".WCF_N
."_package_installation_form
228 $statement = WCF
::getDB()->prepareStatement($sql);
229 $statement->execute([
230 $this->installation
->queue
->queueID
235 * Calculates current setup process.
237 * @param string $node
240 public function calculateProgress($node) {
247 FROM wcf".WCF_N
."_package_installation_node
248 WHERE processNo = ?";
249 $statement = WCF
::getDB()->prepareStatement($sql);
250 $statement->execute([
251 $this->installation
->queue
->processNo
253 while ($row = $statement->fetchArray()) {
258 $progress['outstanding']++
;
262 if (!$progress['done']) {
265 else if (!$progress['outstanding']) {
269 $total = $progress['done'] +
$progress['outstanding'];
270 return round(($progress['done'] / $total) * 100);
275 * Duplicates a node by re-inserting it and moving all descendants into a new tree.
277 * @param string $node
278 * @param integer $sequenceNo
280 public function cloneNode($node, $sequenceNo) {
281 $newNode = $this->getToken();
283 // update descendants
284 $sql = "UPDATE wcf".WCF_N
."_package_installation_node
288 $statement = WCF
::getDB()->prepareStatement($sql);
289 $statement->execute([
292 $this->installation
->queue
->processNo
295 // create a copy of current node (prevents empty nodes)
296 $sql = "SELECT nodeType, nodeData, done
297 FROM wcf".WCF_N
."_package_installation_node
301 $statement = WCF
::getDB()->prepareStatement($sql);
302 $statement->execute([
304 $this->installation
->queue
->processNo
,
307 $row = $statement->fetchArray();
309 $sql = "INSERT INTO wcf".WCF_N
."_package_installation_node
310 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData, done)
311 VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
312 $statement = WCF
::getDB()->prepareStatement($sql);
313 $statement->execute([
314 $this->installation
->queue
->queueID
,
315 $this->installation
->queue
->processNo
,
324 // move other child-nodes greater than $sequenceNo into new node
325 $sql = "UPDATE wcf".WCF_N
."_package_installation_node
328 sequenceNo = (sequenceNo - ?)
332 $statement = WCF
::getDB()->prepareStatement($sql);
333 $statement->execute([
338 $this->installation
->queue
->processNo
,
344 * Inserts a node before given target node. Will shift all target
345 * nodes to provide to be descendants of the new node. If you intend
346 * to insert more than a single node, you should prefer shiftNodes().
348 * @param string $beforeNode
349 * @param callable $callback
351 public function insertNode($beforeNode, callable
$callback) {
352 $newNode = $this->getToken();
354 // update descendants
355 $sql = "UPDATE wcf".WCF_N
."_package_installation_node
359 $statement = WCF
::getDB()->prepareStatement($sql);
360 $statement->execute([
363 $this->installation
->queue
->processNo
367 $callback($beforeNode, $newNode);
371 * Shifts nodes to allow dynamic inserts at runtime.
373 * @param string $oldParentNode
374 * @param string $newParentNode
376 public function shiftNodes($oldParentNode, $newParentNode) {
377 $sql = "UPDATE wcf".WCF_N
."_package_installation_node
381 $statement = WCF
::getDB()->prepareStatement($sql);
382 $statement->execute([
385 $this->installation
->queue
->processNo
390 * Builds package node used to install the package itself.
392 protected function buildPackageNode() {
393 if (!empty($this->node
)) {
394 $this->parentNode
= $this->node
;
395 $this->sequenceNo
= 0;
398 $this->node
= $this->getToken();
400 // calculate the number of instances of this package
401 $sql = "INSERT INTO wcf".WCF_N
."_package_installation_node
402 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
403 VALUES (?, ?, ?, ?, ?, ?, ?)";
404 $statement = WCF
::getDB()->prepareStatement($sql);
405 $statement->execute([
406 $this->installation
->queue
->queueID
,
407 $this->installation
->queue
->processNo
,
413 'package' => $this->installation
->getArchive()->getPackageInfo('name'),
414 'packageName' => $this->installation
->getArchive()->getLocalizedPackageInfo('packageName'),
415 'packageDescription' => $this->installation
->getArchive()->getLocalizedPackageInfo('packageDescription'),
416 'packageVersion' => $this->installation
->getArchive()->getPackageInfo('version'),
417 'packageDate' => $this->installation
->getArchive()->getPackageInfo('date'),
418 'packageURL' => $this->installation
->getArchive()->getPackageInfo('packageURL'),
419 'isApplication' => $this->installation
->getArchive()->getPackageInfo('isApplication'),
420 'author' => $this->installation
->getArchive()->getAuthorInfo('author'),
421 'authorURL' => $this->installation
->getArchive()->getAuthorInfo('authorURL') !== null ?
$this->installation
->getArchive()->getAuthorInfo('authorURL') : '',
422 'installDate' => TIME_NOW
,
423 'updateDate' => TIME_NOW
,
424 'requirements' => $this->requirements
430 * Builds nodes for required packages, whereas each has it own node.
433 * @throws SystemException
435 protected function buildRequirementNodes() {
436 $queue = $this->installation
->queue
;
438 // handle requirements
439 $requiredPackages = $this->installation
->getArchive()->getOpenRequirements();
440 foreach ($requiredPackages as $packageName => $package) {
441 if (!isset($package['file'])) {
442 if (isset(self
::$pendingPackages[$packageName]) && (!isset($package['minversion']) || Package
::compareVersion(self
::$pendingPackages[$packageName], $package['minversion']) >= 0)) {
443 // the package will already be installed and no
444 // minversion is given or the package which will be
445 // installed satisfies the minversion, thus we can
446 // ignore this requirement
450 // requirements will be checked once package is about to be installed
451 $this->requirements
[$packageName] = [
452 'minVersion' => isset($package['minversion']) ?
$package['minversion'] : '',
453 'packageID' => $package['packageID']
459 if ($this->node
== '' && !empty($this->parentNode
)) {
460 $this->node
= $this->parentNode
;
464 $index = $this->installation
->getArchive()->getTar()->getIndexByFilename($package['file']);
465 if ($index === false) {
466 // workaround for WCFSetup
467 if (!PACKAGE_ID
&& $packageName == 'com.woltlab.wcf') {
471 throw new SystemException("Unable to find required package '".$package['file']."' within archive of package '".$this->installation
->queue
->package
."'.");
474 $fileName = FileUtil
::getTemporaryFilename('package_', preg_replace('!^.*(?=\.(?:tar\.gz|tgz|tar)$)!i', '', basename($package['file'])));
475 $this->installation
->getArchive()->getTar()->extract($index, $fileName);
478 $archive = new PackageArchive($fileName);
479 $archive->openArchive();
481 // check if delivered package has correct identifier
482 if ($archive->getPackageInfo('name') != $packageName) {
483 throw new SystemException("Invalid package file delivered for '".$packageName."' requirement of package '".$this->installation
->getArchive()->getPackageInfo('name')."' (delivered package: '".$archive->getPackageInfo('name')."').");
486 // check if delivered version satisfies minversion
487 if (isset($package['minversion']) && Package
::compareVersion($package['minversion'], $archive->getPackageInfo('version')) > 0) {
488 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').".");
492 $sql = "SELECT packageID
493 FROM wcf".WCF_N
."_package
495 $statement = WCF
::getDB()->prepareStatement($sql);
496 $statement->execute([$archive->getPackageInfo('name')]);
497 $row = $statement->fetchArray();
498 $packageID = ($row === false) ?
null : $row['packageID'];
500 // check if package will already be installed
501 if (isset(self
::$pendingPackages[$packageName])) {
502 if (Package
::compareVersion(self
::$pendingPackages[$packageName], $archive->getPackageInfo('version')) >= 0) {
503 // the version to be installed satisfies the required version
507 // the new delivered required version of the package has a
508 // higher version number, thus update/replace the existing
509 // package installation queue
514 $queue = PackageInstallationQueueEditor
::create([
515 'parentQueueID' => $queue->queueID
,
516 'processNo' => $queue->processNo
,
517 'userID' => WCF
::getUser()->userID
,
518 'package' => $archive->getPackageInfo('name'),
519 'packageID' => $packageID,
520 'packageName' => $archive->getLocalizedPackageInfo('packageName'),
521 'archive' => $fileName,
522 'action' => $packageID ?
'update' : 'install'
525 self
::$pendingPackages[$archive->getPackageInfo('name')] = $archive->getPackageInfo('version');
528 $installation = new PackageInstallationDispatcher($queue);
529 $installation->nodeBuilder
->setParentNode($this->node
);
530 $installation->nodeBuilder
->buildNodes();
531 $this->node
= $installation->nodeBuilder
->getCurrentNode();
536 * Returns current node
540 public function getCurrentNode() {
545 * Builds package installation plugin nodes, whereas pips could be grouped within
546 * one node, differ from each by nothing but the sequence number.
550 protected function buildPluginNodes() {
551 if (!empty($this->node
)) {
552 $this->parentNode
= $this->node
;
553 $this->sequenceNo
= 0;
556 $this->node
= $this->getToken();
560 $this->emptyNode
= true;
561 $instructions = ($this->installation
->getAction() == 'install') ?
$this->installation
->getArchive()->getInstallInstructions() : $this->installation
->getArchive()->getUpdateInstructions();
562 $count = count($instructions);
564 foreach ($instructions as $pip) {
567 if (isset($pip['attributes']['run']) && ($pip['attributes']['run'] == 'standalone')) {
568 // move into a new node unless current one is empty
569 if (!$this->emptyNode
) {
570 $this->parentNode
= $this->node
;
571 $this->node
= $this->getToken();
572 $this->sequenceNo
= 0;
576 'node' => $this->node
,
577 'parentNode' => $this->parentNode
,
578 'sequenceNo' => $this->sequenceNo
581 // create a new node for following PIPs, unless it is the last one
583 $this->parentNode
= $this->node
;
584 $this->node
= $this->getToken();
585 $this->sequenceNo
= 0;
587 $this->emptyNode
= true;
595 'node' => $this->node
,
596 'parentNode' => $this->parentNode
,
597 'sequenceNo' => $this->sequenceNo
600 $this->emptyNode
= false;
605 if (!empty($pluginNodes)) {
606 $sql = "INSERT INTO wcf".WCF_N
."_package_installation_node
607 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
608 VALUES (?, ?, ?, ?, ?, ?, ?)";
609 $statement = WCF
::getDB()->prepareStatement($sql);
611 foreach ($pluginNodes as $index => $nodeData) {
612 $statement->execute([
613 $this->installation
->queue
->queueID
,
614 $this->installation
->queue
->processNo
,
615 $nodeData['sequenceNo'],
617 $nodeData['parentNode'],
619 serialize($nodeData['data'])
626 * Builds nodes for optional packages, whereas each package exists within
627 * one node with the same parent node, separated by sequence no (which does
628 * not really matter at this point).
630 protected function buildOptionalNodes() {
633 $optionalPackages = $this->installation
->getArchive()->getOptionals();
634 foreach ($optionalPackages as $package) {
635 // check if already installed
636 if (Package
::isAlreadyInstalled($package['name'])) {
641 $index = $this->installation
->getArchive()->getTar()->getIndexByFilename($package['file']);
642 if ($index === false) {
643 throw new SystemException("Unable to find required package '".$package['file']."' within archive.");
646 $fileName = FileUtil
::getTemporaryFilename('package_', preg_replace('!^.*(?=\.(?:tar\.gz|tgz|tar)$)!i', '', basename($package['file'])));
647 $this->installation
->getArchive()->getTar()->extract($index, $fileName);
650 $archive = new PackageArchive($fileName);
651 $archive->openArchive();
653 // check if all requirements are met
654 $isInstallable = true;
655 foreach ($archive->getOpenRequirements() as $packageName => $requiredPackage) {
656 if (!isset($requiredPackage['file'])) {
657 // requirement is neither installed nor shipped, check if it is about to be installed
658 if (!isset(self
::$pendingPackages[$packageName])) {
659 $isInstallable = false;
665 // check for exclusions
666 $excludedPackages = $archive->getConflictedExcludedPackages();
667 if (!empty($excludedPackages)) {
668 $isInstallable = false;
671 $excludingPackages = $archive->getConflictedExcludingPackages();
672 if (!empty($excludingPackages)) {
673 $isInstallable = false;
677 'archive' => $fileName,
678 'isInstallable' => $isInstallable,
679 'package' => $archive->getPackageInfo('name'),
680 'packageName' => $archive->getLocalizedPackageInfo('packageName'),
681 'packageDescription' => $archive->getLocalizedPackageInfo('packageDescription'),
685 self
::$pendingPackages[$archive->getPackageInfo('name')] = $archive->getPackageInfo('version');
688 if (!empty($packages)) {
689 $this->parentNode
= $this->node
;
690 $this->node
= $this->getToken();
691 $this->sequenceNo
= 0;
693 $sql = "INSERT INTO wcf".WCF_N
."_package_installation_node
694 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
695 VALUES (?, ?, ?, ?, ?, ?, ?)";
696 $statement = WCF
::getDB()->prepareStatement($sql);
697 $statement->execute([
698 $this->installation
->queue
->queueID
,
699 $this->installation
->queue
->processNo
,
710 * Recursively build nodes for child queues.
712 protected function buildChildQueues() {
713 $queueList = new PackageInstallationQueueList();
714 $queueList->getConditionBuilder()->add("package_installation_queue.parentQueueID = ?", [$this->installation
->queue
->queueID
]);
715 $queueList->getConditionBuilder()->add("package_installation_queue.queueID NOT IN (SELECT queueID FROM wcf".WCF_N
."_package_installation_node)");
716 $queueList->readObjects();
718 foreach ($queueList as $queue) {
719 $installation = new PackageInstallationDispatcher($queue);
721 // work-around for iterative package updates
722 if ($this->installation
->queue
->action
== 'update' && $queue->package
== $this->installation
->queue
->package
) {
723 $installation->setPreviousPackage([
724 'package' => $this->installation
->getArchive()->getPackageInfo('name'),
725 'packageVersion' => $this->installation
->getArchive()->getPackageInfo('version')
729 $installation->nodeBuilder
->setParentNode($this->node
);
730 $installation->nodeBuilder
->buildNodes();
731 $this->node
= $installation->nodeBuilder
->getCurrentNode();
736 * Returns a short SHA1-hash.
740 protected function getToken() {
741 return mb_substr(StringUtil
::getRandomID(), 0, 8);
745 * Returns queue id based upon current node.
747 * @param integer $processNo
748 * @param string $node
751 public function getQueueByNode($processNo, $node) {
752 $sql = "SELECT queueID
753 FROM wcf".WCF_N
."_package_installation_node
756 $statement = WCF
::getDB()->prepareStatement($sql);
757 $statement->execute([
761 $row = $statement->fetchArray();
763 return $row['queueID'];