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>
20 class PackageInstallationNodeBuilder
23 * true if current node is empty
26 public $emptyNode = true;
29 * active package installation dispatcher
30 * @var PackageInstallationDispatcher
35 * current installation node
41 * current parent installation node
44 public $parentNode = '';
47 * list of requirements to be checked before package installation
50 public $requirements = [];
53 * current sequence number within one node
56 public $sequenceNo = 0;
59 * list of packages about to be installed
62 private static $pendingPackages = [];
65 * Creates a new instance of PackageInstallationNodeBuilder
67 * @param PackageInstallationDispatcher $installation
69 public function __construct(PackageInstallationDispatcher
$installation)
71 $this->installation
= $installation;
77 public function setParentNode(string $parentNode)
79 $this->parentNode
= $parentNode;
83 * Builds nodes for current installation queue.
85 public function buildNodes()
87 $manifest = new PackageManifest($this->installation
->getArchive());
88 $auditLogger = new AuditLogger();
90 $package = $this->installation
->getPackage();
91 switch ($this->installation
->getAction()) {
93 $currentPackageVersion = null;
97 Building installation nodes
98 ===========================
99 Process#: {$this->installation->queue->processNo}
100 Queue#: {$this->installation->queue->queueID}
101 Parent Queue#: {$this->installation->queue->parentQueueID}
102 Parent Node: {$this->parentNode}
104 Archive: {$this->installation->getArchive()->getArchive()}
105 Manifest ({$manifest->getHash()}):
107 {$manifest->getManifest()}
113 $currentPackageVersion = self
::$pendingPackages[$package->package
] ??
$package->packageVersion
;
117 Building update nodes
118 =====================
119 Process#: {$this->installation->queue->processNo}
120 Queue#: {$this->installation->queue->queueID}
121 Parent Queue#: {$this->installation->queue->parentQueueID}
122 Parent Node: {$this->parentNode}
124 Package: {$package->package} ({$currentPackageVersion})
126 Archive: {$this->installation->getArchive()->getArchive()}
127 Manifest ({$manifest->getHash()}):
129 {$manifest->getManifest()}
137 $this->buildRequirementNodes();
139 $this->buildStartMarkerNode($currentPackageVersion);
141 // install package itself
142 if ($this->installation
->getAction() == 'install') {
143 $this->buildPackageNode();
146 // package installation plugins
147 switch ($this->installation
->getAction()) {
149 $instructions = $this->installation
->getArchive()->getInstallInstructions();
153 $instructions = $this->installation
->getArchive()->getUpdateInstructionsFor($currentPackageVersion) ??
[];
157 throw new \
LogicException('Unreachable');
160 $this->buildPluginNodes($instructions);
162 // register package version
163 self
::$pendingPackages[$this->installation
->getArchive()->getPackageInfo('name')] = $this->installation
->getArchive()->getPackageInfo('version');
165 // optional packages (ignored on update)
166 if ($this->installation
->getAction() == 'install') {
167 $this->buildOptionalNodes();
170 if ($this->installation
->getAction() == 'update') {
171 $this->buildPackageNode();
174 $this->buildEndMarkerNode();
178 Finished building nodes
179 =======================
180 Process#: {$this->installation->queue->processNo}
181 Queue#: {$this->installation->queue->queueID}
182 Final Node: {$this->node}
187 $this->buildChildQueues();
191 * Returns the succeeding node.
193 public function getNextNode(string $parentNode = ''): string
196 FROM wcf1_package_installation_node
199 $statement = WCF
::getDB()->prepare($sql);
200 $statement->execute([
201 $this->installation
->queue
->processNo
,
204 $row = $statement->fetchArray();
214 * Returns package name associated with given queue id.
216 public function getPackageNameByQueue(int $queueID): string
218 $sql = "SELECT packageName
219 FROM wcf1_package_installation_queue
221 $statement = WCF
::getDB()->prepare($sql);
222 $statement->execute([$queueID]);
223 $row = $statement->fetchArray();
229 return $row['packageName'];
233 * Returns installation type by queue id.
235 public function getInstallationTypeByQueue(int $queueID): string
237 $sql = "SELECT action
238 FROM wcf1_package_installation_queue
240 $statement = WCF
::getDB()->prepare($sql);
241 $statement->execute([$queueID]);
242 $row = $statement->fetchArray();
244 return $row['action'];
248 * Returns data for current node.
252 public function getNodeData(string $node)
254 $sql = "SELECT nodeType, nodeData, sequenceNo
255 FROM wcf1_package_installation_node
258 ORDER BY sequenceNo ASC";
259 $statement = WCF
::getDB()->prepare($sql);
260 $statement->execute([
261 $this->installation
->queue
->processNo
,
265 return $statement->fetchAll(\PDO
::FETCH_ASSOC
);
269 * Marks a node as completed.
271 public function completeNode(string $node)
273 $sql = "UPDATE wcf1_package_installation_node
277 $statement = WCF
::getDB()->prepare($sql);
278 $statement->execute([
279 $this->installation
->queue
->processNo
,
285 * Removes all nodes associated with queue's process no.
287 * CAUTION: This method SHOULD NOT be called within the installation process!
289 public function purgeNodes()
291 $sql = "DELETE FROM wcf1_package_installation_node
292 WHERE processNo = ?";
293 $statement = WCF
::getDB()->prepare($sql);
294 $statement->execute([
295 $this->installation
->queue
->processNo
,
298 $sql = "DELETE FROM wcf1_package_installation_form
300 $statement = WCF
::getDB()->prepare($sql);
301 $statement->execute([
302 $this->installation
->queue
->queueID
,
307 * Calculates current setup process.
309 * @param string $node
312 public function calculateProgress($node)
320 FROM wcf1_package_installation_node
321 WHERE processNo = ?";
322 $statement = WCF
::getDB()->prepare($sql);
323 $statement->execute([
324 $this->installation
->queue
->processNo
,
326 while ($row = $statement->fetchArray()) {
330 $progress['outstanding']++
;
334 if (!$progress['done']) {
336 } elseif (!$progress['outstanding']) {
339 $total = $progress['done'] +
$progress['outstanding'];
341 return \round
(($progress['done'] / $total) * 100);
346 * Duplicates a node by re-inserting it and moving all descendants into a new tree.
348 * @param string $node
349 * @param int $sequenceNo
351 public function cloneNode($node, $sequenceNo)
353 $newNode = $this->getToken();
355 // update descendants
356 $this->shiftNodes($node, $newNode);
358 // create a copy of current node (prevents empty nodes)
359 $sql = "SELECT nodeType, nodeData, done
360 FROM wcf1_package_installation_node
364 $statement = WCF
::getDB()->prepare($sql);
365 $statement->execute([
367 $this->installation
->queue
->processNo
,
370 $row = $statement->fetchArray();
372 $sql = "INSERT INTO wcf1_package_installation_node
373 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData, done)
374 VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
375 $statement = WCF
::getDB()->prepare($sql);
376 $statement->execute([
377 $this->installation
->queue
->queueID
,
378 $this->installation
->queue
->processNo
,
387 // move other child-nodes greater than $sequenceNo into new node
388 $sql = "UPDATE wcf1_package_installation_node
394 $statement = WCF
::getDB()->prepare($sql);
395 $statement->execute([
399 $this->installation
->queue
->processNo
,
405 * Shifts nodes to allow dynamic inserts at runtime.
407 public function shiftNodes(string $oldParentNode, string $newParentNode)
409 $sql = "UPDATE wcf1_package_installation_node
413 $statement = WCF
::getDB()->prepare($sql);
414 $statement->execute([
417 $this->installation
->queue
->processNo
,
421 protected function buildStartMarkerNode(?
string $currentPackageVersion)
423 if (!empty($this->node
)) {
424 $this->parentNode
= $this->node
;
425 $this->sequenceNo
= 0;
427 $this->node
= $this->getToken();
429 $sql = "INSERT INTO wcf1_package_installation_node
430 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
431 VALUES (?, ?, ?, ?, ?, ?, ?)";
432 $statement = WCF
::getDB()->prepare($sql);
433 $statement->execute([
434 $this->installation
->queue
->queueID
,
435 $this->installation
->queue
->processNo
,
441 'currentPackageVersion' => $currentPackageVersion,
446 protected function buildEndMarkerNode()
448 if (!empty($this->node
)) {
449 $this->parentNode
= $this->node
;
450 $this->sequenceNo
= 0;
452 $this->node
= $this->getToken();
454 $sql = "INSERT INTO wcf1_package_installation_node
455 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
456 VALUES (?, ?, ?, ?, ?, ?, ?)";
457 $statement = WCF
::getDB()->prepare($sql);
458 $statement->execute([
459 $this->installation
->queue
->queueID
,
460 $this->installation
->queue
->processNo
,
470 * Builds package node used to install the package itself.
472 protected function buildPackageNode()
474 if (!empty($this->node
)) {
475 $this->parentNode
= $this->node
;
476 $this->sequenceNo
= 0;
479 $this->node
= $this->getToken();
481 $sql = "INSERT INTO wcf1_package_installation_node
482 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
483 VALUES (?, ?, ?, ?, ?, ?, ?)";
484 $statement = WCF
::getDB()->prepare($sql);
485 $statement->execute([
486 $this->installation
->queue
->queueID
,
487 $this->installation
->queue
->processNo
,
493 'package' => $this->installation
->getArchive()->getPackageInfo('name'),
494 'packageName' => $this->installation
->getArchive()->getLocalizedPackageInfo('packageName'),
495 'packageDescription' => $this->installation
->getArchive()->getLocalizedPackageInfo('packageDescription'),
496 'packageVersion' => $this->installation
->getArchive()->getPackageInfo('version'),
497 'packageDate' => $this->installation
->getArchive()->getPackageInfo('date'),
498 'packageURL' => $this->installation
->getArchive()->getPackageInfo('packageURL'),
499 'isApplication' => $this->installation
->getArchive()->getPackageInfo('isApplication'),
500 'author' => $this->installation
->getArchive()->getAuthorInfo('author'),
501 'authorURL' => $this->installation
->getArchive()->getAuthorInfo('authorURL') ?
: '',
502 'installDate' => TIME_NOW
,
503 'updateDate' => TIME_NOW
,
504 'requirements' => $this->requirements
,
505 'applicationDirectory' => $this->installation
->getArchive()->getPackageInfo('applicationDirectory') ?
: '',
511 * Builds nodes for required packages, whereas each has it own node.
514 * @throws SystemException
516 protected function buildRequirementNodes()
518 $queue = $this->installation
->queue
;
520 // handle requirements
521 $requiredPackages = $this->installation
->getArchive()->getOpenRequirements();
522 foreach ($requiredPackages as $packageName => $package) {
523 if (!isset($package['file'])) {
525 isset(self
::$pendingPackages[$packageName])
527 !isset($package['minversion'])
528 || Package
::compareVersion(self
::$pendingPackages[$packageName], $package['minversion']) >= 0
531 // the package will already be installed and no
532 // minversion is given or the package which will be
533 // installed satisfies the minversion, thus we can
534 // ignore this requirement
538 // requirements will be checked once package is about to be installed
539 $this->requirements
[$packageName] = [
540 'minVersion' => $package['minversion'] ??
'',
541 'packageID' => $package['packageID'],
547 if ($this->node
== '' && !empty($this->parentNode
)) {
548 $this->node
= $this->parentNode
;
552 $index = $this->installation
->getArchive()->getTar()->getIndexByFilename($package['file']);
553 if ($index === false) {
554 // workaround for WCFSetup
555 if (!PACKAGE_ID
&& $packageName == 'com.woltlab.wcf') {
559 throw new SystemException("Unable to find required package '" . $package['file'] . "' within archive of package '" . $this->installation
->queue
->package
. "'.");
562 $fileName = FileUtil
::getTemporaryFilename(
564 \
preg_replace('!^.*(?=\.(?:tar\.gz|tgz|tar)$)!i', '', \basename
($package['file']))
566 $this->installation
->getArchive()->getTar()->extract($index, $fileName);
569 $archive = new PackageArchive($fileName);
570 $archive->openArchive();
572 // check if delivered package has correct identifier
573 if ($archive->getPackageInfo('name') != $packageName) {
574 throw new SystemException("Invalid package file delivered for '" . $packageName . "' requirement of package '" . $this->installation
->getArchive()->getPackageInfo('name') . "' (delivered package: '" . $archive->getPackageInfo('name') . "').");
577 // check if delivered version satisfies minversion
579 isset($package['minversion']) && Package
::compareVersion(
580 $package['minversion'],
581 $archive->getPackageInfo('version')
584 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') . ".");
588 $sql = "SELECT packageID
591 $statement = WCF
::getDB()->prepare($sql);
592 $statement->execute([$archive->getPackageInfo('name')]);
593 $row = $statement->fetchArray();
594 $packageID = ($row === false) ?
null : $row['packageID'];
596 // check if package will already be installed
597 if (isset(self
::$pendingPackages[$packageName])) {
599 Package
::compareVersion(
600 self
::$pendingPackages[$packageName],
601 $archive->getPackageInfo('version')
604 // the version to be installed satisfies the required version
607 // the new delivered required version of the package has a
608 // higher version number, thus update/replace the existing
609 // package installation queue
613 if ($archive->getPackageInfo('name') === 'com.woltlab.wcf') {
614 WCF
::checkWritability();
618 $queue = PackageInstallationQueueEditor
::create([
619 'parentQueueID' => $queue->queueID
,
620 'processNo' => $queue->processNo
,
621 'userID' => WCF
::getUser()->userID
,
622 'package' => $archive->getPackageInfo('name'),
623 'packageID' => $packageID,
624 'packageName' => $archive->getLocalizedPackageInfo('packageName'),
625 'archive' => $fileName,
626 'action' => $packageID ?
'update' : 'install',
630 $installation = new PackageInstallationDispatcher($queue);
631 $installation->nodeBuilder
->setParentNode($this->node
);
632 $installation->nodeBuilder
->buildNodes();
633 $this->node
= $installation->nodeBuilder
->getCurrentNode();
638 * Returns current node
640 public function getCurrentNode(): string
646 * Builds package installation plugin nodes, whereas pips could be grouped within
647 * one node, differ from each by nothing but the sequence number.
651 protected function buildPluginNodes(array $instructions)
653 $count = \
count($instructions);
656 // Abort if an empty list of instructions is received. This most likely indicates that
657 // the update instructions have been erroneously discarded.
658 throw new \
Exception('Received an empty list of instructions.');
661 if (!empty($this->node
)) {
662 $this->parentNode
= $this->node
;
663 $this->sequenceNo
= 0;
666 $this->node
= $this->getToken();
668 $this->emptyNode
= true;
672 foreach ($instructions as $pip) {
675 if (isset($pip['attributes']['run']) && ($pip['attributes']['run'] == 'standalone')) {
676 // move into a new node unless current one is empty
677 if (!$this->emptyNode
) {
678 $this->parentNode
= $this->node
;
679 $this->node
= $this->getToken();
680 $this->sequenceNo
= 0;
684 'node' => $this->node
,
685 'parentNode' => $this->parentNode
,
686 'sequenceNo' => $this->sequenceNo
,
689 // create a new node for following PIPs, unless it is the last one
691 $this->parentNode
= $this->node
;
692 $this->node
= $this->getToken();
693 $this->sequenceNo
= 0;
695 $this->emptyNode
= true;
702 'node' => $this->node
,
703 'parentNode' => $this->parentNode
,
704 'sequenceNo' => $this->sequenceNo
,
707 $this->emptyNode
= false;
711 \assert
($pluginNodes !== []);
713 $sql = "INSERT INTO wcf1_package_installation_node
714 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
715 VALUES (?, ?, ?, ?, ?, ?, ?)";
716 $statement = WCF
::getDB()->prepare($sql);
718 foreach ($pluginNodes as $nodeData) {
719 $statement->execute([
720 $this->installation
->queue
->queueID
,
721 $this->installation
->queue
->processNo
,
722 $nodeData['sequenceNo'],
724 $nodeData['parentNode'],
726 \
serialize($nodeData['data']),
732 * Builds nodes for optional packages, whereas each package exists within
733 * one node with the same parent node, separated by sequence no (which does
734 * not really matter at this point).
736 protected function buildOptionalNodes()
740 $optionalPackages = $this->installation
->getArchive()->getOptionals();
741 foreach ($optionalPackages as $package) {
742 // check if already installed
743 if (Package
::isAlreadyInstalled($package['name'])) {
748 $index = $this->installation
->getArchive()->getTar()->getIndexByFilename($package['file']);
749 if ($index === false) {
750 throw new SystemException("Unable to find required package '" . $package['file'] . "' within archive.");
753 $fileName = FileUtil
::getTemporaryFilename(
755 \
preg_replace('!^.*(?=\.(?:tar\.gz|tgz|tar)$)!i', '', \basename
($package['file']))
757 $this->installation
->getArchive()->getTar()->extract($index, $fileName);
760 $archive = new PackageArchive($fileName);
761 $archive->openArchive();
763 // check if all requirements are met
764 $isInstallable = true;
765 foreach ($archive->getOpenRequirements() as $packageName => $requiredPackage) {
766 if (!isset($requiredPackage['file'])) {
767 // requirement is neither installed nor shipped, check if it is about to be installed
768 if (!isset(self
::$pendingPackages[$packageName])) {
769 $isInstallable = false;
775 // check for exclusions
776 $excludedPackages = $archive->getConflictedExcludedPackages();
777 if (!empty($excludedPackages)) {
778 $isInstallable = false;
781 $excludingPackages = $archive->getConflictedExcludingPackages();
782 if (!empty($excludingPackages)) {
783 $isInstallable = false;
787 'archive' => $fileName,
788 'isInstallable' => $isInstallable,
789 'package' => $archive->getPackageInfo('name'),
790 'packageName' => $archive->getLocalizedPackageInfo('packageName'),
791 'packageDescription' => $archive->getLocalizedPackageInfo('packageDescription'),
795 self
::$pendingPackages[$archive->getPackageInfo('name')] = $archive->getPackageInfo('version');
798 if (!empty($packages)) {
799 $this->parentNode
= $this->node
;
800 $this->node
= $this->getToken();
801 $this->sequenceNo
= 0;
803 $sql = "INSERT INTO wcf1_package_installation_node
804 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
805 VALUES (?, ?, ?, ?, ?, ?, ?)";
806 $statement = WCF
::getDB()->prepare($sql);
807 $statement->execute([
808 $this->installation
->queue
->queueID
,
809 $this->installation
->queue
->processNo
,
814 \
serialize($packages),
820 * Recursively build nodes for child queues.
822 protected function buildChildQueues()
824 $queueList = new PackageInstallationQueueList();
825 $queueList->getConditionBuilder()->add(
826 "package_installation_queue.parentQueueID = ?",
827 [$this->installation
->queue
->queueID
]
829 $queueList->getConditionBuilder()->add("package_installation_queue.queueID NOT IN (
831 FROM wcf" . WCF_N
. "_package_installation_node
833 $queueList->readObjects();
835 foreach ($queueList as $queue) {
836 $installation = new PackageInstallationDispatcher($queue);
838 // work-around for iterative package updates
839 if (isset(self
::$pendingPackages[$queue->package
])) {
840 $installation->setPreviousPackage([
841 'package' => $queue->package
,
842 'packageVersion' => self
::$pendingPackages[$queue->package
],
846 $installation->nodeBuilder
->setParentNode($this->node
);
847 $installation->nodeBuilder
->buildNodes();
848 $this->node
= $installation->nodeBuilder
->getCurrentNode();
853 * Returns a short SHA1-hash.
855 protected function getToken(): string
857 return \
mb_substr(StringUtil
::getRandomID(), 0, 8);
861 * Returns queue id based upon current node.
865 public function getQueueByNode(int $processNo, string $node)
867 $sql = "SELECT queueID
868 FROM wcf1_package_installation_node
871 $statement = WCF
::getDB()->prepare($sql);
872 $statement->execute([
876 $row = $statement->fetchArray();
878 if ($row === false) {
879 // PHP <7.4 _silently_ returns `null` when attempting to read an array index
880 // when the source value equals `false`.
884 return $row['queueID'];