Merge branch '3.0'
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / package / PackageInstallationNodeBuilder.class.php
1 <?php
2 namespace wcf\system\package;
3 use wcf\data\package\installation\queue\PackageInstallationQueueEditor;
4 use wcf\data\package\installation\queue\PackageInstallationQueueList;
5 use wcf\data\package\Package;
6 use wcf\system\exception\SystemException;
7 use wcf\system\WCF;
8 use wcf\util\FileUtil;
9 use wcf\util\StringUtil;
10
11 /**
12 * Creates a logical node-based installation tree.
13 *
14 * @author Alexander Ebert
15 * @copyright 2001-2018 WoltLab GmbH
16 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
17 * @package WoltLabSuite\Core\System\Package
18 */
19 class PackageInstallationNodeBuilder {
20 /**
21 * true if current node is empty
22 * @var boolean
23 */
24 public $emptyNode = true;
25
26 /**
27 * active package installation dispatcher
28 * @var PackageInstallationDispatcher
29 */
30 public $installation = null;
31
32 /**
33 * current installation node
34 * @var string
35 */
36 public $node = '';
37
38 /**
39 * current parent installation node
40 * @var string
41 */
42 public $parentNode = '';
43
44 /**
45 * list of requirements to be checked before package installation
46 * @var mixed[][]
47 */
48 public $requirements = [];
49
50 /**
51 * current sequence number within one node
52 * @var integer
53 */
54 public $sequenceNo = 0;
55
56 /**
57 * list of packages about to be installed
58 * @var string[]
59 */
60 protected static $pendingPackages = [];
61
62 /**
63 * Creates a new instance of PackageInstallationNodeBuilder
64 *
65 * @param PackageInstallationDispatcher $installation
66 */
67 public function __construct(PackageInstallationDispatcher $installation) {
68 $this->installation = $installation;
69 }
70
71 /**
72 * Sets parent node.
73 *
74 * @param string $parentNode
75 */
76 public function setParentNode($parentNode) {
77 $this->parentNode = $parentNode;
78 }
79
80 /**
81 * Builds nodes for current installation queue.
82 */
83 public function buildNodes() {
84 // required packages
85 $this->buildRequirementNodes();
86
87 // register package version
88 self::$pendingPackages[$this->installation->getArchive()->getPackageInfo('name')] = $this->installation->getArchive()->getPackageInfo('version');
89
90 // install package itself
91 if ($this->installation->queue->action == 'install') {
92 $this->buildPackageNode();
93 }
94
95 // package installation plugins
96 $this->buildPluginNodes();
97
98 // optional packages (ignored on update)
99 if ($this->installation->queue->action == 'install') {
100 $this->buildOptionalNodes();
101 }
102
103 if ($this->installation->queue->action == 'update') {
104 $this->buildPackageNode();
105 }
106
107 // child queues
108 $this->buildChildQueues();
109 }
110
111 /**
112 * Returns the succeeding node.
113 *
114 * @param string $parentNode
115 * @return string
116 */
117 public function getNextNode($parentNode = '') {
118 $sql = "SELECT node
119 FROM wcf".WCF_N."_package_installation_node
120 WHERE processNo = ?
121 AND parentNode = ?";
122 $statement = WCF::getDB()->prepareStatement($sql);
123 $statement->execute([
124 $this->installation->queue->processNo,
125 $parentNode
126 ]);
127 $row = $statement->fetchArray();
128
129 if (!$row) {
130 return '';
131 }
132
133 return $row['node'];
134 }
135
136 /**
137 * Returns package name associated with given queue id.
138 *
139 * @param integer $queueID
140 * @return string
141 */
142 public function getPackageNameByQueue($queueID) {
143 $sql = "SELECT packageName
144 FROM wcf".WCF_N."_package_installation_queue
145 WHERE queueID = ?";
146 $statement = WCF::getDB()->prepareStatement($sql);
147 $statement->execute([$queueID]);
148 $row = $statement->fetchArray();
149
150 if (!$row) {
151 return '';
152 }
153
154 return $row['packageName'];
155 }
156
157 /**
158 * Returns installation type by queue id.
159 *
160 * @param integer $queueID
161 * @return string
162 */
163 public function getInstallationTypeByQueue($queueID) {
164 $sql = "SELECT action
165 FROM wcf".WCF_N."_package_installation_queue
166 WHERE queueID = ?";
167 $statement = WCF::getDB()->prepareStatement($sql);
168 $statement->execute([$queueID]);
169 $row = $statement->fetchArray();
170
171 return $row['action'];
172 }
173
174 /**
175 * Returns data for current node.
176 *
177 * @param string $node
178 * @return array
179 */
180 public function getNodeData($node) {
181 $sql = "SELECT nodeType, nodeData, sequenceNo
182 FROM wcf".WCF_N."_package_installation_node
183 WHERE processNo = ?
184 AND node = ?
185 ORDER BY sequenceNo ASC";
186 $statement = WCF::getDB()->prepareStatement($sql);
187 $statement->execute([
188 $this->installation->queue->processNo,
189 $node
190 ]);
191
192 return $statement->fetchAll(\PDO::FETCH_ASSOC);
193 }
194
195 /**
196 * Marks a node as completed.
197 *
198 * @param string $node
199 */
200 public function completeNode($node) {
201 $sql = "UPDATE wcf".WCF_N."_package_installation_node
202 SET done = 1
203 WHERE processNo = ?
204 AND node = ?";
205 $statement = WCF::getDB()->prepareStatement($sql);
206 $statement->execute([
207 $this->installation->queue->processNo,
208 $node
209 ]);
210 }
211
212 /**
213 * Removes all nodes associated with queue's process no.
214 *
215 * CAUTION: This method SHOULD NOT be called within the installation process!
216 */
217 public function purgeNodes() {
218 $sql = "DELETE FROM wcf".WCF_N."_package_installation_node
219 WHERE processNo = ?";
220 $statement = WCF::getDB()->prepareStatement($sql);
221 $statement->execute([
222 $this->installation->queue->processNo
223 ]);
224
225 $sql = "DELETE FROM wcf".WCF_N."_package_installation_form
226 WHERE queueID = ?";
227 $statement = WCF::getDB()->prepareStatement($sql);
228 $statement->execute([
229 $this->installation->queue->queueID
230 ]);
231 }
232
233 /**
234 * Calculates current setup process.
235 *
236 * @param string $node
237 * @return integer
238 */
239 public function calculateProgress($node) {
240 $progress = [
241 'done' => 0,
242 'outstanding' => 0
243 ];
244
245 $sql = "SELECT done
246 FROM wcf".WCF_N."_package_installation_node
247 WHERE processNo = ?";
248 $statement = WCF::getDB()->prepareStatement($sql);
249 $statement->execute([
250 $this->installation->queue->processNo
251 ]);
252 while ($row = $statement->fetchArray()) {
253 if ($row['done']) {
254 $progress['done']++;
255 }
256 else {
257 $progress['outstanding']++;
258 }
259 }
260
261 if (!$progress['done']) {
262 return 0;
263 }
264 else if (!$progress['outstanding']) {
265 return 100;
266 }
267 else {
268 $total = $progress['done'] + $progress['outstanding'];
269 return round(($progress['done'] / $total) * 100);
270 }
271 }
272
273 /**
274 * Duplicates a node by re-inserting it and moving all descendants into a new tree.
275 *
276 * @param string $node
277 * @param integer $sequenceNo
278 */
279 public function cloneNode($node, $sequenceNo) {
280 $newNode = $this->getToken();
281
282 // update descendants
283 $sql = "UPDATE wcf".WCF_N."_package_installation_node
284 SET parentNode = ?
285 WHERE parentNode = ?
286 AND processNo = ?";
287 $statement = WCF::getDB()->prepareStatement($sql);
288 $statement->execute([
289 $newNode,
290 $node,
291 $this->installation->queue->processNo
292 ]);
293
294 // create a copy of current node (prevents empty nodes)
295 $sql = "SELECT nodeType, nodeData, done
296 FROM wcf".WCF_N."_package_installation_node
297 WHERE node = ?
298 AND processNo = ?
299 AND sequenceNo = ?";
300 $statement = WCF::getDB()->prepareStatement($sql);
301 $statement->execute([
302 $node,
303 $this->installation->queue->processNo,
304 $sequenceNo
305 ]);
306 $row = $statement->fetchArray();
307
308 $sql = "INSERT INTO wcf".WCF_N."_package_installation_node
309 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData, done)
310 VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
311 $statement = WCF::getDB()->prepareStatement($sql);
312 $statement->execute([
313 $this->installation->queue->queueID,
314 $this->installation->queue->processNo,
315 0,
316 $newNode,
317 $node,
318 $row['nodeType'],
319 $row['nodeData'],
320 $row['done']
321 ]);
322
323 // move other child-nodes greater than $sequenceNo into new node
324 $sql = "UPDATE wcf".WCF_N."_package_installation_node
325 SET parentNode = ?,
326 node = ?,
327 sequenceNo = (sequenceNo - ?)
328 WHERE node = ?
329 AND processNo = ?
330 AND sequenceNo > ?";
331 $statement = WCF::getDB()->prepareStatement($sql);
332 $statement->execute([
333 $node,
334 $newNode,
335 $sequenceNo,
336 $node,
337 $this->installation->queue->processNo,
338 $sequenceNo
339 ]);
340 }
341
342 /**
343 * Inserts a node before given target node. Will shift all target
344 * nodes to provide to be descendants of the new node. If you intend
345 * to insert more than a single node, you should prefer shiftNodes().
346 *
347 * @param string $beforeNode
348 * @param callable $callback
349 */
350 public function insertNode($beforeNode, callable $callback) {
351 $newNode = $this->getToken();
352
353 // update descendants
354 $sql = "UPDATE wcf".WCF_N."_package_installation_node
355 SET parentNode = ?
356 WHERE parentNode = ?
357 AND processNo = ?";
358 $statement = WCF::getDB()->prepareStatement($sql);
359 $statement->execute([
360 $newNode,
361 $beforeNode,
362 $this->installation->queue->processNo
363 ]);
364
365 // execute callback
366 $callback($beforeNode, $newNode);
367 }
368
369 /**
370 * Shifts nodes to allow dynamic inserts at runtime.
371 *
372 * @param string $oldParentNode
373 * @param string $newParentNode
374 */
375 public function shiftNodes($oldParentNode, $newParentNode) {
376 $sql = "UPDATE wcf".WCF_N."_package_installation_node
377 SET parentNode = ?
378 WHERE parentNode = ?
379 AND processNo = ?";
380 $statement = WCF::getDB()->prepareStatement($sql);
381 $statement->execute([
382 $newParentNode,
383 $oldParentNode,
384 $this->installation->queue->processNo
385 ]);
386 }
387
388 /**
389 * Builds package node used to install the package itself.
390 */
391 protected function buildPackageNode() {
392 if (!empty($this->node)) {
393 $this->parentNode = $this->node;
394 $this->sequenceNo = 0;
395 }
396
397 $this->node = $this->getToken();
398
399 // calculate the number of instances of this package
400 $sql = "INSERT INTO wcf".WCF_N."_package_installation_node
401 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
402 VALUES (?, ?, ?, ?, ?, ?, ?)";
403 $statement = WCF::getDB()->prepareStatement($sql);
404 $statement->execute([
405 $this->installation->queue->queueID,
406 $this->installation->queue->processNo,
407 $this->sequenceNo,
408 $this->node,
409 $this->parentNode,
410 'package',
411 serialize([
412 'package' => $this->installation->getArchive()->getPackageInfo('name'),
413 'packageName' => $this->installation->getArchive()->getLocalizedPackageInfo('packageName'),
414 'packageDescription' => $this->installation->getArchive()->getLocalizedPackageInfo('packageDescription'),
415 'packageVersion' => $this->installation->getArchive()->getPackageInfo('version'),
416 'packageDate' => $this->installation->getArchive()->getPackageInfo('date'),
417 'packageURL' => $this->installation->getArchive()->getPackageInfo('packageURL'),
418 'isApplication' => $this->installation->getArchive()->getPackageInfo('isApplication'),
419 'author' => $this->installation->getArchive()->getAuthorInfo('author'),
420 'authorURL' => $this->installation->getArchive()->getAuthorInfo('authorURL') !== null ? $this->installation->getArchive()->getAuthorInfo('authorURL') : '',
421 'installDate' => TIME_NOW,
422 'updateDate' => TIME_NOW,
423 'requirements' => $this->requirements
424 ])
425 ]);
426 }
427
428 /**
429 * Builds nodes for required packages, whereas each has it own node.
430 *
431 * @return string
432 * @throws SystemException
433 */
434 protected function buildRequirementNodes() {
435 $queue = $this->installation->queue;
436
437 // handle requirements
438 $requiredPackages = $this->installation->getArchive()->getOpenRequirements();
439 foreach ($requiredPackages as $packageName => $package) {
440 if (!isset($package['file'])) {
441 if (isset(self::$pendingPackages[$packageName]) && (!isset($package['minversion']) || Package::compareVersion(self::$pendingPackages[$packageName], $package['minversion']) >= 0)) {
442 // the package will already be installed and no
443 // minversion is given or the package which will be
444 // installed satisfies the minversion, thus we can
445 // ignore this requirement
446 continue;
447 }
448
449 // requirements will be checked once package is about to be installed
450 $this->requirements[$packageName] = [
451 'minVersion' => isset($package['minversion']) ? $package['minversion'] : '',
452 'packageID' => $package['packageID']
453 ];
454
455 continue;
456 }
457
458 if ($this->node == '' && !empty($this->parentNode)) {
459 $this->node = $this->parentNode;
460 }
461
462 // extract package
463 $index = $this->installation->getArchive()->getTar()->getIndexByFilename($package['file']);
464 if ($index === false) {
465 // workaround for WCFSetup
466 if (!PACKAGE_ID && $packageName == 'com.woltlab.wcf') {
467 continue;
468 }
469
470 throw new SystemException("Unable to find required package '".$package['file']."' within archive of package '".$this->installation->queue->package."'.");
471 }
472
473 $fileName = FileUtil::getTemporaryFilename('package_', preg_replace('!^.*(?=\.(?:tar\.gz|tgz|tar)$)!i', '', basename($package['file'])));
474 $this->installation->getArchive()->getTar()->extract($index, $fileName);
475
476 // get archive data
477 $archive = new PackageArchive($fileName);
478 $archive->openArchive();
479
480 // check if delivered package has correct identifier
481 if ($archive->getPackageInfo('name') != $packageName) {
482 throw new SystemException("Invalid package file delivered for '".$packageName."' requirement of package '".$this->installation->getArchive()->getPackageInfo('name')."' (delivered package: '".$archive->getPackageInfo('name')."').");
483 }
484
485 // check if delivered version satisfies minversion
486 if (isset($package['minversion']) && Package::compareVersion($package['minversion'], $archive->getPackageInfo('version')) > 0) {
487 throw new SystemException("Package '".$this->installation->getArchive()->getPackageInfo('name')."' requires package '".$packageName."' at least in version ".$package['minversion'].", but only delivers version ".$archive->getPackageInfo('version').".");
488 }
489
490 // get package id
491 $sql = "SELECT packageID
492 FROM wcf".WCF_N."_package
493 WHERE package = ?";
494 $statement = WCF::getDB()->prepareStatement($sql);
495 $statement->execute([$archive->getPackageInfo('name')]);
496 $row = $statement->fetchArray();
497 $packageID = ($row === false) ? null : $row['packageID'];
498
499 // check if package will already be installed
500 if (isset(self::$pendingPackages[$packageName])) {
501 if (Package::compareVersion(self::$pendingPackages[$packageName], $archive->getPackageInfo('version')) >= 0) {
502 // the version to be installed satisfies the required version
503 continue;
504 }
505 else {
506 // the new delivered required version of the package has a
507 // higher version number, thus update/replace the existing
508 // package installation queue
509 }
510 }
511
512 // create new queue
513 $queue = PackageInstallationQueueEditor::create([
514 'parentQueueID' => $queue->queueID,
515 'processNo' => $queue->processNo,
516 'userID' => WCF::getUser()->userID,
517 'package' => $archive->getPackageInfo('name'),
518 'packageID' => $packageID,
519 'packageName' => $archive->getLocalizedPackageInfo('packageName'),
520 'archive' => $fileName,
521 'action' => $packageID ? 'update' : 'install'
522 ]);
523
524 self::$pendingPackages[$archive->getPackageInfo('name')] = $archive->getPackageInfo('version');
525
526 // spawn nodes
527 $installation = new PackageInstallationDispatcher($queue);
528 $installation->nodeBuilder->setParentNode($this->node);
529 $installation->nodeBuilder->buildNodes();
530 $this->node = $installation->nodeBuilder->getCurrentNode();
531 }
532 }
533
534 /**
535 * Returns current node
536 *
537 * @return string
538 */
539 public function getCurrentNode() {
540 return $this->node;
541 }
542
543 /**
544 * Builds package installation plugin nodes, whereas pips could be grouped within
545 * one node, differ from each by nothing but the sequence number.
546 *
547 * @return string
548 */
549 protected function buildPluginNodes() {
550 if (!empty($this->node)) {
551 $this->parentNode = $this->node;
552 $this->sequenceNo = 0;
553 }
554
555 $this->node = $this->getToken();
556
557 $pluginNodes = [];
558
559 $this->emptyNode = true;
560 $instructions = ($this->installation->getAction() == 'install') ? $this->installation->getArchive()->getInstallInstructions() : $this->installation->getArchive()->getUpdateInstructions();
561 $count = count($instructions);
562 $i = 0;
563 foreach ($instructions as $pip) {
564 $i++;
565
566 if (isset($pip['attributes']['run']) && ($pip['attributes']['run'] == 'standalone')) {
567 // move into a new node unless current one is empty
568 if (!$this->emptyNode) {
569 $this->parentNode = $this->node;
570 $this->node = $this->getToken();
571 $this->sequenceNo = 0;
572 }
573 $pluginNodes[] = [
574 'data' => $pip,
575 'node' => $this->node,
576 'parentNode' => $this->parentNode,
577 'sequenceNo' => $this->sequenceNo
578 ];
579
580 // create a new node for following PIPs, unless it is the last one
581 if ($i < $count) {
582 $this->parentNode = $this->node;
583 $this->node = $this->getToken();
584 $this->sequenceNo = 0;
585
586 $this->emptyNode = true;
587 }
588 }
589 else {
590 $this->sequenceNo++;
591
592 $pluginNodes[] = [
593 'data' => $pip,
594 'node' => $this->node,
595 'parentNode' => $this->parentNode,
596 'sequenceNo' => $this->sequenceNo
597 ];
598
599 $this->emptyNode = false;
600 }
601 }
602
603 // insert nodes
604 if (!empty($pluginNodes)) {
605 $sql = "INSERT INTO wcf".WCF_N."_package_installation_node
606 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
607 VALUES (?, ?, ?, ?, ?, ?, ?)";
608 $statement = WCF::getDB()->prepareStatement($sql);
609
610 foreach ($pluginNodes as $index => $nodeData) {
611 $statement->execute([
612 $this->installation->queue->queueID,
613 $this->installation->queue->processNo,
614 $nodeData['sequenceNo'],
615 $nodeData['node'],
616 $nodeData['parentNode'],
617 'pip',
618 serialize($nodeData['data'])
619 ]);
620 }
621 }
622 }
623
624 /**
625 * Builds nodes for optional packages, whereas each package exists within
626 * one node with the same parent node, separated by sequence no (which does
627 * not really matter at this point).
628 */
629 protected function buildOptionalNodes() {
630 $packages = [];
631
632 $optionalPackages = $this->installation->getArchive()->getOptionals();
633 foreach ($optionalPackages as $package) {
634 // check if already installed
635 if (Package::isAlreadyInstalled($package['name'])) {
636 continue;
637 }
638
639 // extract package
640 $index = $this->installation->getArchive()->getTar()->getIndexByFilename($package['file']);
641 if ($index === false) {
642 throw new SystemException("Unable to find required package '".$package['file']."' within archive.");
643 }
644
645 $fileName = FileUtil::getTemporaryFilename('package_', preg_replace('!^.*(?=\.(?:tar\.gz|tgz|tar)$)!i', '', basename($package['file'])));
646 $this->installation->getArchive()->getTar()->extract($index, $fileName);
647
648 // get archive data
649 $archive = new PackageArchive($fileName);
650 $archive->openArchive();
651
652 // check if all requirements are met
653 $isInstallable = true;
654 foreach ($archive->getOpenRequirements() as $packageName => $requiredPackage) {
655 if (!isset($requiredPackage['file'])) {
656 // requirement is neither installed nor shipped, check if it is about to be installed
657 if (!isset(self::$pendingPackages[$packageName])) {
658 $isInstallable = false;
659 break;
660 }
661 }
662 }
663
664 // check for exclusions
665 $excludedPackages = $archive->getConflictedExcludedPackages();
666 if (!empty($excludedPackages)) {
667 $isInstallable = false;
668 }
669
670 $excludingPackages = $archive->getConflictedExcludingPackages();
671 if (!empty($excludingPackages)) {
672 $isInstallable = false;
673 }
674
675 $packages[] = [
676 'archive' => $fileName,
677 'isInstallable' => $isInstallable,
678 'package' => $archive->getPackageInfo('name'),
679 'packageName' => $archive->getLocalizedPackageInfo('packageName'),
680 'packageDescription' => $archive->getLocalizedPackageInfo('packageDescription'),
681 'selected' => 0
682 ];
683
684 self::$pendingPackages[$archive->getPackageInfo('name')] = $archive->getPackageInfo('version');
685 }
686
687 if (!empty($packages)) {
688 $this->parentNode = $this->node;
689 $this->node = $this->getToken();
690 $this->sequenceNo = 0;
691
692 $sql = "INSERT INTO wcf".WCF_N."_package_installation_node
693 (queueID, processNo, sequenceNo, node, parentNode, nodeType, nodeData)
694 VALUES (?, ?, ?, ?, ?, ?, ?)";
695 $statement = WCF::getDB()->prepareStatement($sql);
696 $statement->execute([
697 $this->installation->queue->queueID,
698 $this->installation->queue->processNo,
699 $this->sequenceNo,
700 $this->node,
701 $this->parentNode,
702 'optionalPackages',
703 serialize($packages)
704 ]);
705 }
706 }
707
708 /**
709 * Recursively build nodes for child queues.
710 */
711 protected function buildChildQueues() {
712 $queueList = new PackageInstallationQueueList();
713 $queueList->getConditionBuilder()->add("package_installation_queue.parentQueueID = ?", [$this->installation->queue->queueID]);
714 $queueList->getConditionBuilder()->add("package_installation_queue.queueID NOT IN (SELECT queueID FROM wcf".WCF_N."_package_installation_node)");
715 $queueList->readObjects();
716
717 foreach ($queueList as $queue) {
718 $installation = new PackageInstallationDispatcher($queue);
719
720 // work-around for iterative package updates
721 if ($this->installation->queue->action == 'update' && $queue->package == $this->installation->queue->package) {
722 $installation->setPreviousPackage([
723 'package' => $this->installation->getArchive()->getPackageInfo('name'),
724 'packageVersion' => $this->installation->getArchive()->getPackageInfo('version')
725 ]);
726 }
727
728 $installation->nodeBuilder->setParentNode($this->node);
729 $installation->nodeBuilder->buildNodes();
730 $this->node = $installation->nodeBuilder->getCurrentNode();
731 }
732 }
733
734 /**
735 * Returns a short SHA1-hash.
736 *
737 * @return string
738 */
739 protected function getToken() {
740 return mb_substr(StringUtil::getRandomID(), 0, 8);
741 }
742
743 /**
744 * Returns queue id based upon current node.
745 *
746 * @param integer $processNo
747 * @param string $node
748 * @return integer|null
749 */
750 public function getQueueByNode($processNo, $node) {
751 $sql = "SELECT queueID
752 FROM wcf".WCF_N."_package_installation_node
753 WHERE processNo = ?
754 AND node = ?";
755 $statement = WCF::getDB()->prepareStatement($sql);
756 $statement->execute([
757 $processNo,
758 $node
759 ]);
760 $row = $statement->fetchArray();
761
762 if ($row === false) {
763 // PHP <7.4 _silently_ returns `null` when attempting to read an array index
764 // when the source value equals `false`.
765 return null;
766 }
767
768 return $row['queueID'];
769 }
770 }