Add explicit `return null;` statements
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / package / PackageInstallationNodeBuilder.class.php
index 68e72f85e34529e221621cec89e4adb0c8bb0503..91a9f306334fdf26a24efb5b7172b7021138906a 100644 (file)
@@ -1,5 +1,7 @@
 <?php
+
 namespace wcf\system\package;
+
 use wcf\data\package\installation\queue\PackageInstallationQueueEditor;
 use wcf\data\package\installation\queue\PackageInstallationQueueList;
 use wcf\data\package\Package;
@@ -10,761 +12,810 @@ use wcf\util\StringUtil;
 
 /**
  * Creates a logical node-based installation tree.
- * 
- * @author     Alexander Ebert
- * @copyright  2001-2018 WoltLab GmbH
- * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @package    WoltLabSuite\Core\System\Package
+ *
+ * @author  Alexander Ebert
+ * @copyright   2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\System\Package
  */
-class PackageInstallationNodeBuilder {
-       /**
-        * true if current node is empty
-        * @var boolean
-        */
-       public $emptyNode = true;
-       
-       /**
-        * active package installation dispatcher
-        * @var PackageInstallationDispatcher
-        */
-       public $installation = null;
-       
-       /**
-        * current installation node
-        * @var string
-        */
-       public $node = '';
-       
-       /**
-        * current parent installation node
-        * @var string
-        */
-       public $parentNode = '';
-       
-       /**
-        * list of requirements to be checked before package installation
-        * @var mixed[][]
-        */
-       public $requirements = [];
-       
-       /**
-        * current sequence number within one node
-        * @var integer
-        */
-       public $sequenceNo = 0;
-       
-       /**
-        * list of packages about to be installed
-        * @var string[]
-        */
-       protected static $pendingPackages = [];
-       
-       /**
-        * Creates a new instance of PackageInstallationNodeBuilder
-        * 
-        * @param       PackageInstallationDispatcher   $installation
-        */
-       public function __construct(PackageInstallationDispatcher $installation) {
-               $this->installation = $installation;
-       }
-       
-       /**
-        * Sets parent node.
-        * 
-        * @param       string          $parentNode
-        */
-       public function setParentNode($parentNode) {
-               $this->parentNode = $parentNode;
-       }
-       
-       /**
-        * Builds nodes for current installation queue.
-        */
-       public function buildNodes() {
-               // required packages
-               $this->buildRequirementNodes();
-               
-               // register package version
-               self::$pendingPackages[$this->installation->getArchive()->getPackageInfo('name')] = $this->installation->getArchive()->getPackageInfo('version');
-               
-               // install package itself
-               if ($this->installation->queue->action == 'install') {
-                       $this->buildPackageNode();
-               }
-               
-               // package installation plugins
-               $this->buildPluginNodes();
-               
-               // optional packages (ignored on update)
-               if ($this->installation->queue->action == 'install') {
-                       $this->buildOptionalNodes();
-               }
-               
-               if ($this->installation->queue->action == 'update') {
-                       $this->buildPackageNode();
-               }
-               
-               // child queues
-               $this->buildChildQueues();
-       }
-       
-       /**
-        * Returns the succeeding node.
-        * 
-        * @param       string          $parentNode
-        * @return      string
-        */
-       public function getNextNode($parentNode = '') {
-               $sql = "SELECT  node
-                       FROM    wcf".WCF_N."_package_installation_node
-                       WHERE   processNo = ?
-                               AND parentNode = ?";
-               $statement = WCF::getDB()->prepareStatement($sql);
-               $statement->execute([
-                       $this->installation->queue->processNo,
-                       $parentNode
-               ]);
-               $row = $statement->fetchArray();
-               
-               if (!$row) {
-                       return '';
-               }
-               
-               return $row['node'];
-       }
-       
-       /**
-        * Returns package name associated with given queue id.
-        * 
-        * @param       integer         $queueID
-        * @return      string
-        */
-       public function getPackageNameByQueue($queueID) {
-               $sql = "SELECT  packageName
-                       FROM    wcf".WCF_N."_package_installation_queue
-                       WHERE   queueID = ?";
-               $statement = WCF::getDB()->prepareStatement($sql);
-               $statement->execute([$queueID]);
-               $row = $statement->fetchArray();
-               
-               if (!$row) {
-                       return '';
-               }
-               
-               return $row['packageName'];
-       }
-       
-       /**
-        * Returns installation type by queue id.
-        * 
-        * @param       integer         $queueID
-        * @return      string
-        */
-       public function getInstallationTypeByQueue($queueID) {
-               $sql = "SELECT  action
-                       FROM    wcf".WCF_N."_package_installation_queue
-                       WHERE   queueID = ?";
-               $statement = WCF::getDB()->prepareStatement($sql);
-               $statement->execute([$queueID]);
-               $row = $statement->fetchArray();
-               
-               return $row['action'];
-       }
-       
-       /**
-        * Returns data for current node.
-        * 
-        * @param       string          $node
-        * @return      array
-        */
-       public function getNodeData($node) {
-               $sql = "SELECT          nodeType, nodeData, sequenceNo
-                       FROM            wcf".WCF_N."_package_installation_node
-                       WHERE           processNo = ?
-                                       AND node = ?
-                       ORDER BY        sequenceNo ASC";
-               $statement = WCF::getDB()->prepareStatement($sql);
-               $statement->execute([
-                       $this->installation->queue->processNo,
-                       $node
-               ]);
-               
-               return $statement->fetchAll(\PDO::FETCH_ASSOC);
-       }
-       
-       /**
-        * Marks a node as completed.
-        * 
-        * @param       string          $node
-        */
-       public function completeNode($node) {
-               $sql = "UPDATE  wcf".WCF_N."_package_installation_node
-                       SET     done = 1
-                       WHERE   processNo = ?
-                               AND node = ?";
-               $statement = WCF::getDB()->prepareStatement($sql);
-               $statement->execute([
-                       $this->installation->queue->processNo,
-                       $node
-               ]);
-       }
-       
-       /**
-        * Removes all nodes associated with queue's process no.
-        * 
-        * CAUTION: This method SHOULD NOT be called within the installation process!
-        */
-       public function purgeNodes() {
-               $sql = "DELETE FROM     wcf".WCF_N."_package_installation_node
-                       WHERE           processNo = ?";
-               $statement = WCF::getDB()->prepareStatement($sql);
-               $statement->execute([
-                       $this->installation->queue->processNo
-               ]);
-               
-               $sql = "DELETE FROM     wcf".WCF_N."_package_installation_form
-                       WHERE           queueID = ?";
-               $statement = WCF::getDB()->prepareStatement($sql);
-               $statement->execute([
-                       $this->installation->queue->queueID
-               ]);
-       }
-       
-       /**
-        * Calculates current setup process.
-        * 
-        * @param       string          $node
-        * @return      integer
-        */
-       public function calculateProgress($node) {
-               $progress = [
-                       'done' => 0,
-                       'outstanding' => 0
-               ];
-               
-               $sql = "SELECT  done
-                       FROM    wcf".WCF_N."_package_installation_node
-                       WHERE   processNo = ?";
-               $statement = WCF::getDB()->prepareStatement($sql);
-               $statement->execute([
-                       $this->installation->queue->processNo
-               ]);
-               while ($row = $statement->fetchArray()) {
-                       if ($row['done']) {
-                               $progress['done']++;
-                       }
-                       else {
-                               $progress['outstanding']++;
-                       }
-               }
-               
-               if (!$progress['done']) {
-                       return 0;
-               }
-               else if (!$progress['outstanding']) {
-                       return 100;
-               }
-               else {
-                       $total = $progress['done'] + $progress['outstanding'];
-                       return round(($progress['done'] / $total) * 100);
-               }
-       }
-       
-       /**
-        * Duplicates a node by re-inserting it and moving all descendants into a new tree.
-        * 
-        * @param       string          $node
-        * @param       integer         $sequenceNo
-        */
-       public function cloneNode($node, $sequenceNo) {
-               $newNode = $this->getToken();
-               
-               // update descendants
-               $sql = "UPDATE  wcf".WCF_N."_package_installation_node
-                       SET     parentNode = ?
-                       WHERE   parentNode = ?
-                               AND processNo = ?";
-               $statement = WCF::getDB()->prepareStatement($sql);
-               $statement->execute([
-                       $newNode,
-                       $node,
-                       $this->installation->queue->processNo
-               ]);
-               
-               // create a copy of current node (prevents empty nodes)
-               $sql = "SELECT  nodeType, nodeData, done
-                       FROM    wcf".WCF_N."_package_installation_node
-                       WHERE   node = ?
-                               AND processNo = ?
-                               AND sequenceNo = ?";
-               $statement = WCF::getDB()->prepareStatement($sql);
-               $statement->execute([
-                       $node,
-                       $this->installation->queue->processNo,
-                       $sequenceNo
-               ]);
-               $row = $statement->fetchArray();
-               
-               $sql = "INSERT INTO     wcf".WCF_N."_package_installation_node
-                                       (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData, done)
-                       VALUES          (?, ?, ?, ?, ?, ?, ?, ?)";
-               $statement = WCF::getDB()->prepareStatement($sql);
-               $statement->execute([
-                       $this->installation->queue->queueID,
-                       $this->installation->queue->processNo,
-                       0,
-                       $newNode,
-                       $node,
-                       $row['nodeType'],
-                       $row['nodeData'],
-                       $row['done']
-               ]);
-               
-               // move other child-nodes greater than $sequenceNo into new node
-               $sql = "UPDATE  wcf".WCF_N."_package_installation_node
-                       SET     parentNode = ?,
-                               node = ?,
-                               sequenceNo = (sequenceNo - ?)
-                       WHERE   node = ?
-                               AND processNo = ?
-                               AND sequenceNo > ?";
-               $statement = WCF::getDB()->prepareStatement($sql);
-               $statement->execute([
-                       $node,
-                       $newNode,
-                       $sequenceNo,
-                       $node,
-                       $this->installation->queue->processNo,
-                       $sequenceNo
-               ]);
-       }
-       
-       /**
-        * Inserts a node before given target node. Will shift all target
-        * nodes to provide to be descendants of the new node. If you intend
-        * to insert more than a single node, you should prefer shiftNodes().
-        * 
-        * @param       string          $beforeNode
-        * @param       callable        $callback
-        */
-       public function insertNode($beforeNode, callable $callback) {
-               $newNode = $this->getToken();
-               
-               // update descendants
-               $sql = "UPDATE  wcf".WCF_N."_package_installation_node
-                       SET     parentNode = ?
-                       WHERE   parentNode = ?
-                               AND processNo = ?";
-               $statement = WCF::getDB()->prepareStatement($sql);
-               $statement->execute([
-                       $newNode,
-                       $beforeNode,
-                       $this->installation->queue->processNo
-               ]);
-               
-               // execute callback
-               $callback($beforeNode, $newNode);
-       }
-       
-       /**
-        * Shifts nodes to allow dynamic inserts at runtime.
-        * 
-        * @param       string          $oldParentNode
-        * @param       string          $newParentNode
-        */
-       public function shiftNodes($oldParentNode, $newParentNode) {
-               $sql = "UPDATE  wcf".WCF_N."_package_installation_node
-                       SET     parentNode = ?
-                       WHERE   parentNode = ?
-                               AND processNo = ?";
-               $statement = WCF::getDB()->prepareStatement($sql);
-               $statement->execute([
-                       $newParentNode,
-                       $oldParentNode,
-                       $this->installation->queue->processNo
-               ]);
-       }
-       
-       /**
-        * Builds package node used to install the package itself.
-        */
-       protected function buildPackageNode() {
-               if (!empty($this->node)) {
-                       $this->parentNode = $this->node;
-                       $this->sequenceNo = 0;
-               }
-               
-               $this->node = $this->getToken();
-               
-               // calculate the number of instances of this package
-               $sql = "INSERT INTO     wcf".WCF_N."_package_installation_node
-                                       (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
-                       VALUES          (?, ?, ?, ?, ?, ?, ?)";
-               $statement = WCF::getDB()->prepareStatement($sql);
-               $statement->execute([
-                       $this->installation->queue->queueID,
-                       $this->installation->queue->processNo,
-                       $this->sequenceNo,
-                       $this->node,
-                       $this->parentNode,
-                       'package',
-                       serialize([
-                               'package' => $this->installation->getArchive()->getPackageInfo('name'),
-                               'packageName' => $this->installation->getArchive()->getLocalizedPackageInfo('packageName'),
-                               'packageDescription' => $this->installation->getArchive()->getLocalizedPackageInfo('packageDescription'),
-                               'packageVersion' => $this->installation->getArchive()->getPackageInfo('version'),
-                               'packageDate' => $this->installation->getArchive()->getPackageInfo('date'),
-                               'packageURL' => $this->installation->getArchive()->getPackageInfo('packageURL'),
-                               'isApplication' => $this->installation->getArchive()->getPackageInfo('isApplication'),
-                               'author' => $this->installation->getArchive()->getAuthorInfo('author'),
-                               'authorURL' => $this->installation->getArchive()->getAuthorInfo('authorURL') !== null ? $this->installation->getArchive()->getAuthorInfo('authorURL') : '',
-                               'installDate' => TIME_NOW,
-                               'updateDate' => TIME_NOW,
-                               'requirements' => $this->requirements
-                       ])
-               ]);
-       }
-       
-       /**
-        * Builds nodes for required packages, whereas each has it own node.
-        * 
-        * @return      string
-        * @throws      SystemException
-        */
-       protected function buildRequirementNodes() {
-               $queue = $this->installation->queue;
-               
-               // handle requirements
-               $requiredPackages = $this->installation->getArchive()->getOpenRequirements();
-               foreach ($requiredPackages as $packageName => $package) {
-                       if (!isset($package['file'])) {
-                               if (isset(self::$pendingPackages[$packageName]) && (!isset($package['minversion']) || Package::compareVersion(self::$pendingPackages[$packageName], $package['minversion']) >= 0)) {
-                                       // the package will already be installed and no
-                                       // minversion is given or the package which will be
-                                       // installed satisfies the minversion, thus we can
-                                       // ignore this requirement
-                                       continue;
-                               }
-                               
-                               // requirements will be checked once package is about to be installed
-                               $this->requirements[$packageName] = [
-                                       'minVersion' => isset($package['minversion']) ? $package['minversion'] : '',
-                                       'packageID' => $package['packageID']
-                               ];
-                               
-                               continue;
-                       }
-                       
-                       if ($this->node == '' && !empty($this->parentNode)) {
-                               $this->node = $this->parentNode;
-                       }
-                       
-                       // extract package
-                       $index = $this->installation->getArchive()->getTar()->getIndexByFilename($package['file']);
-                       if ($index === false) {
-                               // workaround for WCFSetup
-                               if (!PACKAGE_ID && $packageName == 'com.woltlab.wcf') {
-                                       continue;
-                               }
-                               
-                               throw new SystemException("Unable to find required package '".$package['file']."' within archive of package '".$this->installation->queue->package."'.");
-                       }
-                       
-                       $fileName = FileUtil::getTemporaryFilename('package_', preg_replace('!^.*(?=\.(?:tar\.gz|tgz|tar)$)!i', '', basename($package['file'])));
-                       $this->installation->getArchive()->getTar()->extract($index, $fileName);
-                       
-                       // get archive data
-                       $archive = new PackageArchive($fileName);
-                       $archive->openArchive();
-                       
-                       // check if delivered package has correct identifier
-                       if ($archive->getPackageInfo('name') != $packageName) {
-                               throw new SystemException("Invalid package file delivered for '".$packageName."' requirement of package '".$this->installation->getArchive()->getPackageInfo('name')."' (delivered package: '".$archive->getPackageInfo('name')."').");
-                       }
-                       
-                       // check if delivered version satisfies minversion
-                       if (isset($package['minversion']) && Package::compareVersion($package['minversion'], $archive->getPackageInfo('version')) > 0) {
-                               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').".");
-                       }
-                       
-                       // get package id
-                       $sql = "SELECT  packageID
-                               FROM    wcf".WCF_N."_package
-                               WHERE   package = ?";
-                       $statement = WCF::getDB()->prepareStatement($sql);
-                       $statement->execute([$archive->getPackageInfo('name')]);
-                       $row = $statement->fetchArray();
-                       $packageID = ($row === false) ? null : $row['packageID'];
-                       
-                       // check if package will already be installed
-                       if (isset(self::$pendingPackages[$packageName])) {
-                               if (Package::compareVersion(self::$pendingPackages[$packageName], $archive->getPackageInfo('version')) >= 0) {
-                                       // the version to be installed satisfies the required version
-                                       continue;
-                               }
-                               else {
-                                       // the new delivered required version of the package has a
-                                       // higher version number, thus update/replace the existing
-                                       // package installation queue
-                               }
-                       }
-                       
-                       // create new queue
-                       $queue = PackageInstallationQueueEditor::create([
-                               'parentQueueID' => $queue->queueID,
-                               'processNo' => $queue->processNo,
-                               'userID' => WCF::getUser()->userID,
-                               'package' => $archive->getPackageInfo('name'),
-                               'packageID' => $packageID,
-                               'packageName' => $archive->getLocalizedPackageInfo('packageName'),
-                               'archive' => $fileName,
-                               'action' => $packageID ? 'update' : 'install'
-                       ]);
-                       
-                       self::$pendingPackages[$archive->getPackageInfo('name')] = $archive->getPackageInfo('version');
-                       
-                       // spawn nodes
-                       $installation = new PackageInstallationDispatcher($queue);
-                       $installation->nodeBuilder->setParentNode($this->node);
-                       $installation->nodeBuilder->buildNodes();
-                       $this->node = $installation->nodeBuilder->getCurrentNode();
-               }
-       }
-       
-       /**
-        * Returns current node
-        * 
-        * @return      string
-        */
-       public function getCurrentNode() {
-               return $this->node;
-       }
-       
-       /**
-        * Builds package installation plugin nodes, whereas pips could be grouped within
-        * one node, differ from each by nothing but the sequence number.
-        * 
-        * @return      string
-        */
-       protected function buildPluginNodes() {
-               if (!empty($this->node)) {
-                       $this->parentNode = $this->node;
-                       $this->sequenceNo = 0;
-               }
-               
-               $this->node = $this->getToken();
-               
-               $pluginNodes = [];
-               
-               $this->emptyNode = true;
-               $instructions = ($this->installation->getAction() == 'install') ? $this->installation->getArchive()->getInstallInstructions() : $this->installation->getArchive()->getUpdateInstructions();
-               $count = count($instructions);
-               $i = 0;
-               foreach ($instructions as $pip) {
-                       $i++;
-                       
-                       if (isset($pip['attributes']['run']) && ($pip['attributes']['run'] == 'standalone')) {
-                               // move into a new node unless current one is empty
-                               if (!$this->emptyNode) {
-                                       $this->parentNode = $this->node;
-                                       $this->node = $this->getToken();
-                                       $this->sequenceNo = 0;
-                               }
-                               $pluginNodes[] = [
-                                       'data' => $pip,
-                                       'node' => $this->node,
-                                       'parentNode' => $this->parentNode,
-                                       'sequenceNo' => $this->sequenceNo
-                               ];
-                               
-                               // create a new node for following PIPs, unless it is the last one
-                               if ($i < $count) {
-                                       $this->parentNode = $this->node;
-                                       $this->node = $this->getToken();
-                                       $this->sequenceNo = 0;
-                                       
-                                       $this->emptyNode = true;
-                               }
-                       }
-                       else {
-                               $this->sequenceNo++;
-                               
-                               $pluginNodes[] = [
-                                       'data' => $pip,
-                                       'node' => $this->node,
-                                       'parentNode' => $this->parentNode,
-                                       'sequenceNo' => $this->sequenceNo
-                               ];
-                               
-                               $this->emptyNode = false;
-                       }
-               }
-               
-               // insert nodes
-               if (!empty($pluginNodes)) {
-                       $sql = "INSERT INTO     wcf".WCF_N."_package_installation_node
-                                               (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
-                               VALUES          (?, ?, ?, ?, ?, ?, ?)";
-                       $statement = WCF::getDB()->prepareStatement($sql);
-                       
-                       foreach ($pluginNodes as $index => $nodeData) {
-                               $statement->execute([
-                                       $this->installation->queue->queueID,
-                                       $this->installation->queue->processNo,
-                                       $nodeData['sequenceNo'],
-                                       $nodeData['node'],
-                                       $nodeData['parentNode'],
-                                       'pip',
-                                       serialize($nodeData['data'])
-                               ]);
-                       }
-               }
-       }
-       
-       /**
-        * Builds nodes for optional packages, whereas each package exists within
-        * one node with the same parent node, separated by sequence no (which does
-        * not really matter at this point).
-        */
-       protected function buildOptionalNodes() {
-               $packages = [];
-               
-               $optionalPackages = $this->installation->getArchive()->getOptionals();
-               foreach ($optionalPackages as $package) {
-                       // check if already installed
-                       if (Package::isAlreadyInstalled($package['name'])) {
-                               continue;
-                       }
-                       
-                       // extract package
-                       $index = $this->installation->getArchive()->getTar()->getIndexByFilename($package['file']);
-                       if ($index === false) {
-                               throw new SystemException("Unable to find required package '".$package['file']."' within archive.");
-                       }
-                       
-                       $fileName = FileUtil::getTemporaryFilename('package_', preg_replace('!^.*(?=\.(?:tar\.gz|tgz|tar)$)!i', '', basename($package['file'])));
-                       $this->installation->getArchive()->getTar()->extract($index, $fileName);
-                       
-                       // get archive data
-                       $archive = new PackageArchive($fileName);
-                       $archive->openArchive();
-                       
-                       // check if all requirements are met
-                       $isInstallable = true;
-                       foreach ($archive->getOpenRequirements() as $packageName => $requiredPackage) {
-                               if (!isset($requiredPackage['file'])) {
-                                       // requirement is neither installed nor shipped, check if it is about to be installed
-                                       if (!isset(self::$pendingPackages[$packageName])) {
-                                               $isInstallable = false;
-                                               break;
-                                       }
-                               }
-                       }
-                       
-                       // check for exclusions
-                       $excludedPackages = $archive->getConflictedExcludedPackages();
-                       if (!empty($excludedPackages)) {
-                               $isInstallable = false;
-                       }
-                       
-                       $excludingPackages = $archive->getConflictedExcludingPackages();
-                       if (!empty($excludingPackages)) {
-                               $isInstallable = false;
-                       }
-                       
-                       $packages[] = [
-                               'archive' => $fileName,
-                               'isInstallable' => $isInstallable,
-                               'package' => $archive->getPackageInfo('name'),
-                               'packageName' => $archive->getLocalizedPackageInfo('packageName'),
-                               'packageDescription' => $archive->getLocalizedPackageInfo('packageDescription'),
-                               'selected' => 0
-                       ];
-                       
-                       self::$pendingPackages[$archive->getPackageInfo('name')] = $archive->getPackageInfo('version');
-               }
-               
-               if (!empty($packages)) {
-                       $this->parentNode = $this->node;
-                       $this->node = $this->getToken();
-                       $this->sequenceNo = 0;
-                       
-                       $sql = "INSERT INTO     wcf".WCF_N."_package_installation_node
-                                               (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
-                               VALUES          (?, ?, ?, ?, ?, ?, ?)";
-                       $statement = WCF::getDB()->prepareStatement($sql);
-                       $statement->execute([
-                               $this->installation->queue->queueID,
-                               $this->installation->queue->processNo,
-                               $this->sequenceNo,
-                               $this->node,
-                               $this->parentNode,
-                               'optionalPackages',
-                               serialize($packages)
-                       ]);
-               }
-       }
-       
-       /**
-        * Recursively build nodes for child queues.
-        */
-       protected function buildChildQueues() {
-               $queueList = new PackageInstallationQueueList();
-               $queueList->getConditionBuilder()->add("package_installation_queue.parentQueueID = ?", [$this->installation->queue->queueID]);
-               $queueList->getConditionBuilder()->add("package_installation_queue.queueID NOT IN (SELECT queueID FROM wcf".WCF_N."_package_installation_node)");
-               $queueList->readObjects();
-               
-               foreach ($queueList as $queue) {
-                       $installation = new PackageInstallationDispatcher($queue);
-                       
-                       // work-around for iterative package updates
-                       if ($this->installation->queue->action == 'update' && $queue->package == $this->installation->queue->package) {
-                               $installation->setPreviousPackage([
-                                       'package' => $this->installation->getArchive()->getPackageInfo('name'),
-                                       'packageVersion' => $this->installation->getArchive()->getPackageInfo('version')
-                               ]);
-                       }
-                       
-                       $installation->nodeBuilder->setParentNode($this->node);
-                       $installation->nodeBuilder->buildNodes();
-                       $this->node = $installation->nodeBuilder->getCurrentNode();
-               }
-       }
-       
-       /**
-        * Returns a short SHA1-hash.
-        * 
-        * @return      string
-        */
-       protected function getToken() {
-               return mb_substr(StringUtil::getRandomID(), 0, 8);
-       }
-       
-       /**
-        * Returns queue id based upon current node.
-        * 
-        * @param       integer         $processNo
-        * @param       string          $node
-        * @return      integer|null
-        */
-       public function getQueueByNode($processNo, $node) {
-               $sql = "SELECT  queueID
-                       FROM    wcf".WCF_N."_package_installation_node
-                       WHERE   processNo = ?
-                               AND node = ?";
-               $statement = WCF::getDB()->prepareStatement($sql);
-               $statement->execute([
-                       $processNo,
-                       $node
-               ]);
-               $row = $statement->fetchArray();
-               
-               if ($row === false) {
-                       // PHP <7.4 _silently_ returns `null` when attempting to read an array index
-                       // when the source value equals `false`.
-                       return null;
-               }
-               
-               return $row['queueID'];
-       }
+class PackageInstallationNodeBuilder
+{
+    /**
+     * true if current node is empty
+     * @var bool
+     */
+    public $emptyNode = true;
+
+    /**
+     * active package installation dispatcher
+     * @var PackageInstallationDispatcher
+     */
+    public $installation;
+
+    /**
+     * current installation node
+     * @var string
+     */
+    public $node = '';
+
+    /**
+     * current parent installation node
+     * @var string
+     */
+    public $parentNode = '';
+
+    /**
+     * list of requirements to be checked before package installation
+     * @var mixed[][]
+     */
+    public $requirements = [];
+
+    /**
+     * current sequence number within one node
+     * @var int
+     */
+    public $sequenceNo = 0;
+
+    /**
+     * list of packages about to be installed
+     * @var string[]
+     */
+    protected static $pendingPackages = [];
+
+    /**
+     * Creates a new instance of PackageInstallationNodeBuilder
+     *
+     * @param PackageInstallationDispatcher $installation
+     */
+    public function __construct(PackageInstallationDispatcher $installation)
+    {
+        $this->installation = $installation;
+    }
+
+    /**
+     * Sets parent node.
+     *
+     * @param string $parentNode
+     */
+    public function setParentNode($parentNode)
+    {
+        $this->parentNode = $parentNode;
+    }
+
+    /**
+     * Builds nodes for current installation queue.
+     */
+    public function buildNodes()
+    {
+        // required packages
+        $this->buildRequirementNodes();
+
+        // register package version
+        self::$pendingPackages[$this->installation->getArchive()->getPackageInfo('name')] = $this->installation->getArchive()->getPackageInfo('version');
+
+        // install package itself
+        if ($this->installation->queue->action == 'install') {
+            $this->buildPackageNode();
+        }
+
+        // package installation plugins
+        $this->buildPluginNodes();
+
+        // optional packages (ignored on update)
+        if ($this->installation->queue->action == 'install') {
+            $this->buildOptionalNodes();
+        }
+
+        if ($this->installation->queue->action == 'update') {
+            $this->buildPackageNode();
+        }
+
+        // child queues
+        $this->buildChildQueues();
+    }
+
+    /**
+     * Returns the succeeding node.
+     *
+     * @param string $parentNode
+     * @return  string
+     */
+    public function getNextNode($parentNode = '')
+    {
+        $sql = "SELECT  node
+                FROM    wcf" . WCF_N . "_package_installation_node
+                WHERE   processNo = ?
+                    AND parentNode = ?";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([
+            $this->installation->queue->processNo,
+            $parentNode,
+        ]);
+        $row = $statement->fetchArray();
+
+        if (!$row) {
+            return '';
+        }
+
+        return $row['node'];
+    }
+
+    /**
+     * Returns package name associated with given queue id.
+     *
+     * @param int $queueID
+     * @return  string
+     */
+    public function getPackageNameByQueue($queueID)
+    {
+        $sql = "SELECT  packageName
+                FROM    wcf" . WCF_N . "_package_installation_queue
+                WHERE   queueID = ?";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([$queueID]);
+        $row = $statement->fetchArray();
+
+        if (!$row) {
+            return '';
+        }
+
+        return $row['packageName'];
+    }
+
+    /**
+     * Returns installation type by queue id.
+     *
+     * @param int $queueID
+     * @return  string
+     */
+    public function getInstallationTypeByQueue($queueID)
+    {
+        $sql = "SELECT  action
+                FROM    wcf" . WCF_N . "_package_installation_queue
+                WHERE   queueID = ?";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([$queueID]);
+        $row = $statement->fetchArray();
+
+        return $row['action'];
+    }
+
+    /**
+     * Returns data for current node.
+     *
+     * @param string $node
+     * @return  array
+     */
+    public function getNodeData($node)
+    {
+        $sql = "SELECT      nodeType, nodeData, sequenceNo
+                FROM        wcf" . WCF_N . "_package_installation_node
+                WHERE       processNo = ?
+                        AND node = ?
+                ORDER BY    sequenceNo ASC";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([
+            $this->installation->queue->processNo,
+            $node,
+        ]);
+
+        return $statement->fetchAll(\PDO::FETCH_ASSOC);
+    }
+
+    /**
+     * Marks a node as completed.
+     *
+     * @param string $node
+     */
+    public function completeNode($node)
+    {
+        $sql = "UPDATE  wcf" . WCF_N . "_package_installation_node
+                SET     done = 1
+                WHERE   processNo = ?
+                    AND node = ?";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([
+            $this->installation->queue->processNo,
+            $node,
+        ]);
+    }
+
+    /**
+     * Removes all nodes associated with queue's process no.
+     *
+     * CAUTION: This method SHOULD NOT be called within the installation process!
+     */
+    public function purgeNodes()
+    {
+        $sql = "DELETE FROM wcf" . WCF_N . "_package_installation_node
+                WHERE       processNo = ?";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([
+            $this->installation->queue->processNo,
+        ]);
+
+        $sql = "DELETE FROM wcf" . WCF_N . "_package_installation_form
+                WHERE       queueID = ?";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([
+            $this->installation->queue->queueID,
+        ]);
+    }
+
+    /**
+     * Calculates current setup process.
+     *
+     * @param string $node
+     * @return  int
+     */
+    public function calculateProgress($node)
+    {
+        $progress = [
+            'done' => 0,
+            'outstanding' => 0,
+        ];
+
+        $sql = "SELECT  done
+                FROM    wcf" . WCF_N . "_package_installation_node
+                WHERE   processNo = ?";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([
+            $this->installation->queue->processNo,
+        ]);
+        while ($row = $statement->fetchArray()) {
+            if ($row['done']) {
+                $progress['done']++;
+            } else {
+                $progress['outstanding']++;
+            }
+        }
+
+        if (!$progress['done']) {
+            return 0;
+        } elseif (!$progress['outstanding']) {
+            return 100;
+        } else {
+            $total = $progress['done'] + $progress['outstanding'];
+
+            return \round(($progress['done'] / $total) * 100);
+        }
+    }
+
+    /**
+     * Duplicates a node by re-inserting it and moving all descendants into a new tree.
+     *
+     * @param string $node
+     * @param int $sequenceNo
+     */
+    public function cloneNode($node, $sequenceNo)
+    {
+        $newNode = $this->getToken();
+
+        // update descendants
+        $sql = "UPDATE  wcf" . WCF_N . "_package_installation_node
+                SET     parentNode = ?
+                WHERE   parentNode = ?
+                    AND processNo = ?";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([
+            $newNode,
+            $node,
+            $this->installation->queue->processNo,
+        ]);
+
+        // create a copy of current node (prevents empty nodes)
+        $sql = "SELECT  nodeType, nodeData, done
+                FROM    wcf" . WCF_N . "_package_installation_node
+                WHERE   node = ?
+                    AND processNo = ?
+                    AND sequenceNo = ?";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([
+            $node,
+            $this->installation->queue->processNo,
+            $sequenceNo,
+        ]);
+        $row = $statement->fetchArray();
+
+        $sql = "INSERT INTO wcf" . WCF_N . "_package_installation_node
+                            (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData, done)
+                VALUES      (?, ?, ?, ?, ?, ?, ?, ?)";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([
+            $this->installation->queue->queueID,
+            $this->installation->queue->processNo,
+            0,
+            $newNode,
+            $node,
+            $row['nodeType'],
+            $row['nodeData'],
+            $row['done'],
+        ]);
+
+        // move other child-nodes greater than $sequenceNo into new node
+        $sql = "UPDATE  wcf" . WCF_N . "_package_installation_node
+                SET     parentNode = ?,
+                        node = ?,
+                        sequenceNo = (sequenceNo - ?)
+                WHERE   node = ?
+                    AND processNo = ?
+                    AND sequenceNo > ?";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([
+            $node,
+            $newNode,
+            $sequenceNo,
+            $node,
+            $this->installation->queue->processNo,
+            $sequenceNo,
+        ]);
+    }
+
+    /**
+     * Inserts a node before given target node. Will shift all target
+     * nodes to provide to be descendants of the new node. If you intend
+     * to insert more than a single node, you should prefer shiftNodes().
+     *
+     * @param string $beforeNode
+     * @param callable $callback
+     */
+    public function insertNode($beforeNode, callable $callback)
+    {
+        $newNode = $this->getToken();
+
+        // update descendants
+        $sql = "UPDATE  wcf" . WCF_N . "_package_installation_node
+                SET     parentNode = ?
+                WHERE   parentNode = ?
+                    AND processNo = ?";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([
+            $newNode,
+            $beforeNode,
+            $this->installation->queue->processNo,
+        ]);
+
+        // execute callback
+        $callback($beforeNode, $newNode);
+    }
+
+    /**
+     * Shifts nodes to allow dynamic inserts at runtime.
+     *
+     * @param string $oldParentNode
+     * @param string $newParentNode
+     */
+    public function shiftNodes($oldParentNode, $newParentNode)
+    {
+        $sql = "UPDATE  wcf" . WCF_N . "_package_installation_node
+                SET     parentNode = ?
+                WHERE   parentNode = ?
+                    AND processNo = ?";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([
+            $newParentNode,
+            $oldParentNode,
+            $this->installation->queue->processNo,
+        ]);
+    }
+
+    /**
+     * Builds package node used to install the package itself.
+     */
+    protected function buildPackageNode()
+    {
+        if (!empty($this->node)) {
+            $this->parentNode = $this->node;
+            $this->sequenceNo = 0;
+        }
+
+        $this->node = $this->getToken();
+
+        $sql = "INSERT INTO wcf" . WCF_N . "_package_installation_node
+                            (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
+                VALUES      (?, ?, ?, ?, ?, ?, ?)";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([
+            $this->installation->queue->queueID,
+            $this->installation->queue->processNo,
+            $this->sequenceNo,
+            $this->node,
+            $this->parentNode,
+            'package',
+            \serialize([
+                'package' => $this->installation->getArchive()->getPackageInfo('name'),
+                'packageName' => $this->installation->getArchive()->getLocalizedPackageInfo('packageName'),
+                'packageDescription' => $this->installation->getArchive()->getLocalizedPackageInfo('packageDescription'),
+                'packageVersion' => $this->installation->getArchive()->getPackageInfo('version'),
+                'packageDate' => $this->installation->getArchive()->getPackageInfo('date'),
+                'packageURL' => $this->installation->getArchive()->getPackageInfo('packageURL'),
+                'isApplication' => $this->installation->getArchive()->getPackageInfo('isApplication'),
+                'author' => $this->installation->getArchive()->getAuthorInfo('author'),
+                'authorURL' => $this->installation->getArchive()->getAuthorInfo('authorURL') !== null ? $this->installation->getArchive()->getAuthorInfo('authorURL') : '',
+                'installDate' => TIME_NOW,
+                'updateDate' => TIME_NOW,
+                'requirements' => $this->requirements,
+                'applicationDirectory' => $this->installation->getArchive()->getPackageInfo('applicationDirectory') ?: '',
+            ]),
+        ]);
+    }
+
+    /**
+     * Builds nodes for required packages, whereas each has it own node.
+     *
+     * @return  string
+     * @throws  SystemException
+     */
+    protected function buildRequirementNodes()
+    {
+        $queue = $this->installation->queue;
+
+        // handle requirements
+        $requiredPackages = $this->installation->getArchive()->getOpenRequirements();
+        foreach ($requiredPackages as $packageName => $package) {
+            if (!isset($package['file'])) {
+                if (
+                    isset(self::$pendingPackages[$packageName]) && (!isset($package['minversion']) || Package::compareVersion(
+                        self::$pendingPackages[$packageName],
+                        $package['minversion']
+                    ) >= 0)
+                ) {
+                    // the package will already be installed and no
+                    // minversion is given or the package which will be
+                    // installed satisfies the minversion, thus we can
+                    // ignore this requirement
+                    continue;
+                }
+
+                // requirements will be checked once package is about to be installed
+                $this->requirements[$packageName] = [
+                    'minVersion' => $package['minversion'] ?? '',
+                    'packageID' => $package['packageID'],
+                ];
+
+                continue;
+            }
+
+            if ($this->node == '' && !empty($this->parentNode)) {
+                $this->node = $this->parentNode;
+            }
+
+            // extract package
+            $index = $this->installation->getArchive()->getTar()->getIndexByFilename($package['file']);
+            if ($index === false) {
+                // workaround for WCFSetup
+                if (!PACKAGE_ID && $packageName == 'com.woltlab.wcf') {
+                    continue;
+                }
+
+                throw new SystemException("Unable to find required package '" . $package['file'] . "' within archive of package '" . $this->installation->queue->package . "'.");
+            }
+
+            $fileName = FileUtil::getTemporaryFilename(
+                'package_',
+                \preg_replace('!^.*(?=\.(?:tar\.gz|tgz|tar)$)!i', '', \basename($package['file']))
+            );
+            $this->installation->getArchive()->getTar()->extract($index, $fileName);
+
+            // get archive data
+            $archive = new PackageArchive($fileName);
+            $archive->openArchive();
+
+            // check if delivered package has correct identifier
+            if ($archive->getPackageInfo('name') != $packageName) {
+                throw new SystemException("Invalid package file delivered for '" . $packageName . "' requirement of package '" . $this->installation->getArchive()->getPackageInfo('name') . "' (delivered package: '" . $archive->getPackageInfo('name') . "').");
+            }
+
+            // check if delivered version satisfies minversion
+            if (
+                isset($package['minversion']) && Package::compareVersion(
+                    $package['minversion'],
+                    $archive->getPackageInfo('version')
+                ) > 0
+            ) {
+                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') . ".");
+            }
+
+            // get package id
+            $sql = "SELECT  packageID
+                    FROM    wcf" . WCF_N . "_package
+                    WHERE   package = ?";
+            $statement = WCF::getDB()->prepareStatement($sql);
+            $statement->execute([$archive->getPackageInfo('name')]);
+            $row = $statement->fetchArray();
+            $packageID = ($row === false) ? null : $row['packageID'];
+
+            // check if package will already be installed
+            if (isset(self::$pendingPackages[$packageName])) {
+                if (
+                    Package::compareVersion(
+                        self::$pendingPackages[$packageName],
+                        $archive->getPackageInfo('version')
+                    ) >= 0
+                ) {
+                    // the version to be installed satisfies the required version
+                    continue;
+                } else {
+                    // the new delivered required version of the package has a
+                    // higher version number, thus update/replace the existing
+                    // package installation queue
+                }
+            }
+
+            if ($archive->getPackageInfo('name') === 'com.woltlab.wcf') {
+                WCF::checkWritability();
+            }
+
+            // create new queue
+            $queue = PackageInstallationQueueEditor::create([
+                'parentQueueID' => $queue->queueID,
+                'processNo' => $queue->processNo,
+                'userID' => WCF::getUser()->userID,
+                'package' => $archive->getPackageInfo('name'),
+                'packageID' => $packageID,
+                'packageName' => $archive->getLocalizedPackageInfo('packageName'),
+                'archive' => $fileName,
+                'action' => $packageID ? 'update' : 'install',
+            ]);
+
+            self::$pendingPackages[$archive->getPackageInfo('name')] = $archive->getPackageInfo('version');
+
+            // spawn nodes
+            $installation = new PackageInstallationDispatcher($queue);
+            $installation->nodeBuilder->setParentNode($this->node);
+            $installation->nodeBuilder->buildNodes();
+            $this->node = $installation->nodeBuilder->getCurrentNode();
+        }
+    }
+
+    /**
+     * Returns current node
+     *
+     * @return  string
+     */
+    public function getCurrentNode()
+    {
+        return $this->node;
+    }
+
+    /**
+     * Builds package installation plugin nodes, whereas pips could be grouped within
+     * one node, differ from each by nothing but the sequence number.
+     *
+     * @return  string
+     */
+    protected function buildPluginNodes()
+    {
+        if (!empty($this->node)) {
+            $this->parentNode = $this->node;
+            $this->sequenceNo = 0;
+        }
+
+        $this->node = $this->getToken();
+
+        $pluginNodes = [];
+
+        $this->emptyNode = true;
+        $instructions = ($this->installation->getAction() == 'install') ? $this->installation->getArchive()->getInstallInstructions() : $this->installation->getArchive()->getUpdateInstructions();
+        $count = \count($instructions);
+        $i = 0;
+        foreach ($instructions as $pip) {
+            $i++;
+
+            if (isset($pip['attributes']['run']) && ($pip['attributes']['run'] == 'standalone')) {
+                // move into a new node unless current one is empty
+                if (!$this->emptyNode) {
+                    $this->parentNode = $this->node;
+                    $this->node = $this->getToken();
+                    $this->sequenceNo = 0;
+                }
+                $pluginNodes[] = [
+                    'data' => $pip,
+                    'node' => $this->node,
+                    'parentNode' => $this->parentNode,
+                    'sequenceNo' => $this->sequenceNo,
+                ];
+
+                // create a new node for following PIPs, unless it is the last one
+                if ($i < $count) {
+                    $this->parentNode = $this->node;
+                    $this->node = $this->getToken();
+                    $this->sequenceNo = 0;
+
+                    $this->emptyNode = true;
+                }
+            } else {
+                $this->sequenceNo++;
+
+                $pluginNodes[] = [
+                    'data' => $pip,
+                    'node' => $this->node,
+                    'parentNode' => $this->parentNode,
+                    'sequenceNo' => $this->sequenceNo,
+                ];
+
+                $this->emptyNode = false;
+            }
+        }
+
+        // insert nodes
+        if (!empty($pluginNodes)) {
+            $sql = "INSERT INTO wcf" . WCF_N . "_package_installation_node
+                                (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
+                    VALUES      (?, ?, ?, ?, ?, ?, ?)";
+            $statement = WCF::getDB()->prepareStatement($sql);
+
+            foreach ($pluginNodes as $nodeData) {
+                $statement->execute([
+                    $this->installation->queue->queueID,
+                    $this->installation->queue->processNo,
+                    $nodeData['sequenceNo'],
+                    $nodeData['node'],
+                    $nodeData['parentNode'],
+                    'pip',
+                    \serialize($nodeData['data']),
+                ]);
+            }
+        }
+    }
+
+    /**
+     * Builds nodes for optional packages, whereas each package exists within
+     * one node with the same parent node, separated by sequence no (which does
+     * not really matter at this point).
+     */
+    protected function buildOptionalNodes()
+    {
+        $packages = [];
+
+        $optionalPackages = $this->installation->getArchive()->getOptionals();
+        foreach ($optionalPackages as $package) {
+            // check if already installed
+            if (Package::isAlreadyInstalled($package['name'])) {
+                continue;
+            }
+
+            // extract package
+            $index = $this->installation->getArchive()->getTar()->getIndexByFilename($package['file']);
+            if ($index === false) {
+                throw new SystemException("Unable to find required package '" . $package['file'] . "' within archive.");
+            }
+
+            $fileName = FileUtil::getTemporaryFilename(
+                'package_',
+                \preg_replace('!^.*(?=\.(?:tar\.gz|tgz|tar)$)!i', '', \basename($package['file']))
+            );
+            $this->installation->getArchive()->getTar()->extract($index, $fileName);
+
+            // get archive data
+            $archive = new PackageArchive($fileName);
+            $archive->openArchive();
+
+            // check if all requirements are met
+            $isInstallable = true;
+            foreach ($archive->getOpenRequirements() as $packageName => $requiredPackage) {
+                if (!isset($requiredPackage['file'])) {
+                    // requirement is neither installed nor shipped, check if it is about to be installed
+                    if (!isset(self::$pendingPackages[$packageName])) {
+                        $isInstallable = false;
+                        break;
+                    }
+                }
+            }
+
+            // check for exclusions
+            $excludedPackages = $archive->getConflictedExcludedPackages();
+            if (!empty($excludedPackages)) {
+                $isInstallable = false;
+            }
+
+            $excludingPackages = $archive->getConflictedExcludingPackages();
+            if (!empty($excludingPackages)) {
+                $isInstallable = false;
+            }
+
+            $packages[] = [
+                'archive' => $fileName,
+                'isInstallable' => $isInstallable,
+                'package' => $archive->getPackageInfo('name'),
+                'packageName' => $archive->getLocalizedPackageInfo('packageName'),
+                'packageDescription' => $archive->getLocalizedPackageInfo('packageDescription'),
+                'selected' => 0,
+            ];
+
+            self::$pendingPackages[$archive->getPackageInfo('name')] = $archive->getPackageInfo('version');
+        }
+
+        if (!empty($packages)) {
+            $this->parentNode = $this->node;
+            $this->node = $this->getToken();
+            $this->sequenceNo = 0;
+
+            $sql = "INSERT INTO wcf" . WCF_N . "_package_installation_node
+                                (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
+                    VALUES      (?, ?, ?, ?, ?, ?, ?)";
+            $statement = WCF::getDB()->prepareStatement($sql);
+            $statement->execute([
+                $this->installation->queue->queueID,
+                $this->installation->queue->processNo,
+                $this->sequenceNo,
+                $this->node,
+                $this->parentNode,
+                'optionalPackages',
+                \serialize($packages),
+            ]);
+        }
+    }
+
+    /**
+     * Recursively build nodes for child queues.
+     */
+    protected function buildChildQueues()
+    {
+        $queueList = new PackageInstallationQueueList();
+        $queueList->getConditionBuilder()->add(
+            "package_installation_queue.parentQueueID = ?",
+            [$this->installation->queue->queueID]
+        );
+        $queueList->getConditionBuilder()->add("package_installation_queue.queueID NOT IN (
+            SELECT  queueID
+            FROM    wcf" . WCF_N . "_package_installation_node
+        )");
+        $queueList->readObjects();
+
+        foreach ($queueList as $queue) {
+            $installation = new PackageInstallationDispatcher($queue);
+
+            // work-around for iterative package updates
+            if ($this->installation->queue->action == 'update' && $queue->package == $this->installation->queue->package) {
+                $installation->setPreviousPackage([
+                    'package' => $this->installation->getArchive()->getPackageInfo('name'),
+                    'packageVersion' => $this->installation->getArchive()->getPackageInfo('version'),
+                ]);
+            }
+
+            $installation->nodeBuilder->setParentNode($this->node);
+            $installation->nodeBuilder->buildNodes();
+            $this->node = $installation->nodeBuilder->getCurrentNode();
+        }
+    }
+
+    /**
+     * Returns a short SHA1-hash.
+     *
+     * @return  string
+     */
+    protected function getToken()
+    {
+        return \mb_substr(StringUtil::getRandomID(), 0, 8);
+    }
+
+    /**
+     * Returns queue id based upon current node.
+     *
+     * @param int $processNo
+     * @param string $node
+     * @return  int|null
+     */
+    public function getQueueByNode($processNo, $node)
+    {
+        $sql = "SELECT  queueID
+                FROM    wcf" . WCF_N . "_package_installation_node
+                WHERE   processNo = ?
+                    AND node = ?";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([
+            $processNo,
+            $node,
+        ]);
+        $row = $statement->fetchArray();
+
+        if ($row === false) {
+            // PHP <7.4 _silently_ returns `null` when attempting to read an array index
+            // when the source value equals `false`.
+            return null;
+        }
+
+        return $row['queueID'];
+    }
 }