Merge branch '5.2' into 5.3
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / package / PackageInstallationDispatcher.class.php
1 <?php
2 namespace wcf\system\package;
3 use wcf\data\application\Application;
4 use wcf\data\application\ApplicationEditor;
5 use wcf\data\devtools\project\DevtoolsProjectAction;
6 use wcf\data\language\category\LanguageCategory;
7 use wcf\data\language\LanguageEditor;
8 use wcf\data\language\LanguageList;
9 use wcf\data\option\OptionEditor;
10 use wcf\data\package\installation\queue\PackageInstallationQueue;
11 use wcf\data\package\installation\queue\PackageInstallationQueueEditor;
12 use wcf\data\package\Package;
13 use wcf\data\package\PackageEditor;
14 use wcf\data\user\User;
15 use wcf\data\user\UserAction;
16 use wcf\system\application\ApplicationHandler;
17 use wcf\system\cache\builder\TemplateListenerCodeCacheBuilder;
18 use wcf\system\cache\CacheHandler;
19 use wcf\system\database\statement\PreparedStatement;
20 use wcf\system\database\util\PreparedStatementConditionBuilder;
21 use wcf\system\devtools\DevtoolsSetup;
22 use wcf\system\event\EventHandler;
23 use wcf\system\exception\ImplementationException;
24 use wcf\system\exception\SystemException;
25 use wcf\system\form\container\GroupFormElementContainer;
26 use wcf\system\form\container\MultipleSelectionFormElementContainer;
27 use wcf\system\form\element\MultipleSelectionFormElement;
28 use wcf\system\form\element\TextInputFormElement;
29 use wcf\system\form\FormDocument;
30 use wcf\system\language\LanguageFactory;
31 use wcf\system\package\plugin\IPackageInstallationPlugin;
32 use wcf\system\request\LinkHandler;
33 use wcf\system\request\RouteHandler;
34 use wcf\system\setup\IFileHandler;
35 use wcf\system\setup\Installer;
36 use wcf\system\style\StyleHandler;
37 use wcf\system\user\storage\UserStorageHandler;
38 use wcf\system\WCF;
39 use wcf\util\FileUtil;
40 use wcf\util\HeaderUtil;
41 use wcf\util\JSON;
42 use wcf\util\StringUtil;
43
44 /**
45 * PackageInstallationDispatcher handles the whole installation process.
46 *
47 * @author Alexander Ebert
48 * @copyright 2001-2019 WoltLab GmbH
49 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
50 * @package WoltLabSuite\Core\System\Package
51 */
52 class PackageInstallationDispatcher {
53 /**
54 * current installation type
55 * @var string
56 */
57 protected $action = '';
58
59 /**
60 * instance of PackageArchive
61 * @var PackageArchive
62 */
63 public $archive;
64
65 /**
66 * instance of PackageInstallationNodeBuilder
67 * @var PackageInstallationNodeBuilder
68 */
69 public $nodeBuilder;
70
71 /**
72 * instance of Package
73 * @var Package
74 */
75 public $package;
76
77 /**
78 * instance of PackageInstallationQueue
79 * @var PackageInstallationQueue
80 */
81 public $queue;
82
83 /**
84 * default name of the config file
85 * @var string
86 */
87 const CONFIG_FILE = 'app.config.inc.php';
88
89 /**
90 * data of previous package in queue
91 * @var string[]
92 */
93 protected $previousPackageData;
94
95 /**
96 * Creates a new instance of PackageInstallationDispatcher.
97 *
98 * @param PackageInstallationQueue $queue
99 */
100 public function __construct(PackageInstallationQueue $queue) {
101 $this->queue = $queue;
102 $this->nodeBuilder = new PackageInstallationNodeBuilder($this);
103
104 $this->action = $this->queue->action;
105 }
106
107 /**
108 * Sets data of previous package in queue.
109 *
110 * @param string[] $packageData
111 */
112 public function setPreviousPackage(array $packageData) {
113 $this->previousPackageData = $packageData;
114 }
115
116 /**
117 * Installs node components and returns next node.
118 *
119 * @param string $node
120 * @return PackageInstallationStep
121 * @throws SystemException
122 */
123 public function install($node) {
124 $nodes = $this->nodeBuilder->getNodeData($node);
125 if (empty($nodes)) {
126 // guard against possible issues with empty instruction blocks, including
127 // these blocks that contain no valid instructions at all (e.g. typo from
128 // copy & paste)
129 throw new SystemException("Failed to retrieve nodes for identifier '".$node."', the query returned no results.");
130 }
131
132 // invoke node-specific actions
133 $step = null;
134 foreach ($nodes as $data) {
135 $nodeData = unserialize($data['nodeData']);
136 $this->logInstallationStep($data);
137
138 switch ($data['nodeType']) {
139 case 'package':
140 $step = $this->installPackage($nodeData);
141 break;
142
143 case 'pip':
144 $step = $this->executePIP($nodeData);
145 break;
146
147 case 'optionalPackages':
148 $step = $this->selectOptionalPackages($node, $nodeData);
149 break;
150
151 default:
152 die("Unknown node type: '".$data['nodeType']."'");
153 break;
154 }
155
156 if ($step->splitNode()) {
157 $log = 'split node';
158 if ($step->getException() !== null && $step->getException()->getMessage()) {
159 $log .= ': ' . $step->getException()->getMessage();
160 }
161
162 $this->logInstallationStep($data, $log);
163 $this->nodeBuilder->cloneNode($node, $data['sequenceNo']);
164 break;
165 }
166 }
167
168 // mark node as completed
169 $this->nodeBuilder->completeNode($node);
170
171 // assign next node
172 $node = $this->nodeBuilder->getNextNode($node);
173 $step->setNode($node);
174
175 // perform post-install/update actions
176 if ($node == '') {
177 $this->logInstallationStep([], 'start cleanup');
178
179 // update "last update time" option
180 $sql = "UPDATE wcf".WCF_N."_option
181 SET optionValue = ?
182 WHERE optionName = ?";
183 $statement = WCF::getDB()->prepareStatement($sql);
184 $statement->execute([
185 TIME_NOW,
186 'last_update_time'
187 ]);
188
189 // update options.inc.php
190 OptionEditor::resetCache();
191
192 if ($this->action == 'install') {
193 // save localized package infos
194 $this->saveLocalizedPackageInfos();
195
196 // remove all cache files after WCFSetup
197 if (!PACKAGE_ID) {
198 CacheHandler::getInstance()->flushAll();
199
200 $sql = "UPDATE wcf".WCF_N."_option
201 SET optionValue = ?
202 WHERE optionName = ?";
203 $statement = WCF::getDB()->prepareStatement($sql);
204
205 $statement->execute([
206 StringUtil::getUUID(),
207 'wcf_uuid'
208 ]);
209
210 if (file_exists(WCF_DIR . 'cookiePrefix.txt')) {
211 $statement->execute([
212 COOKIE_PREFIX,
213 'cookie_prefix'
214 ]);
215
216 @unlink(WCF_DIR . 'cookiePrefix.txt');
217 }
218
219 $user = new User(1);
220 $statement->execute([
221 $user->username,
222 'mail_from_name'
223 ]);
224 $statement->execute([
225 $user->email,
226 'mail_from_address'
227 ]);
228 $statement->execute([
229 $user->email,
230 'mail_admin_address'
231 ]);
232
233 try {
234 $statement->execute([
235 bin2hex(\random_bytes(20)),
236 'signature_secret'
237 ]);
238 }
239 catch (\Throwable $e) {
240 // ignore, the secret will stay empty and crypto operations
241 // depending on it will fail
242 }
243
244 if (WCF::getSession()->getVar('__wcfSetup_developerMode')) {
245 $statement->execute([
246 1,
247 'enable_debug_mode'
248 ]);
249 $statement->execute([
250 'public',
251 'exception_privacy'
252 ]);
253 $statement->execute([
254 'debugFolder',
255 'mail_send_method'
256 ]);
257 $statement->execute([
258 1,
259 'enable_developer_tools'
260 ]);
261 $statement->execute([
262 1,
263 'log_missing_language_items'
264 ]);
265
266 foreach (DevtoolsSetup::getInstance()->getOptionOverrides() as $optionName => $optionValue) {
267 $statement->execute([
268 $optionValue,
269 $optionName
270 ]);
271 }
272
273 foreach (DevtoolsSetup::getInstance()->getUsers() as $newUser) {
274 try {
275 (new UserAction([], 'create', [
276 'data' => [
277 'email' => $newUser['email'],
278 'password' => $newUser['password'],
279 'username' => $newUser['username']
280 ],
281 'groups' => [
282 1,
283 3
284 ]
285 ]))->executeAction();
286 }
287 catch (SystemException $e) {
288 // ignore errors due to event listeners missing at this
289 // point during installation
290 }
291 }
292
293 if (($importPath = DevtoolsSetup::getInstance()->getDevtoolsImportPath()) !== '') {
294 (new DevtoolsProjectAction([], 'quickSetup', [
295 'path' => $importPath
296 ]))->executeAction();
297 }
298 }
299
300 if (WCF::getSession()->getVar('__wcfSetup_imagick')) {
301 $statement->execute([
302 'imagick',
303 'image_adapter_type',
304 ]);
305 }
306
307 // update options.inc.php
308 OptionEditor::resetCache();
309
310 WCF::getSession()->register('__wcfSetup_completed', true);
311 }
312
313 // rebuild application paths
314 ApplicationHandler::rebuild();
315 }
316
317 // remove template listener cache
318 TemplateListenerCodeCacheBuilder::getInstance()->reset();
319
320 // reset language cache
321 LanguageFactory::getInstance()->clearCache();
322 LanguageFactory::getInstance()->deleteLanguageCache();
323
324 // reset stylesheets
325 StyleHandler::resetStylesheets();
326
327 // clear user storage
328 UserStorageHandler::getInstance()->clear();
329
330 // rebuild config files for affected applications
331 $sql = "SELECT package.packageID
332 FROM wcf".WCF_N."_package_installation_queue queue,
333 wcf".WCF_N."_package package
334 WHERE queue.processNo = ?
335 AND package.packageID = queue.packageID
336 AND package.isApplication = ?";
337 $statement = WCF::getDB()->prepareStatement($sql);
338 $statement->execute([
339 $this->queue->processNo,
340 1
341 ]);
342 while ($row = $statement->fetchArray()) {
343 Package::writeConfigFile($row['packageID']);
344 }
345
346 EventHandler::getInstance()->fireAction($this, 'postInstall');
347
348 // remove archives
349 $sql = "SELECT archive
350 FROM wcf".WCF_N."_package_installation_queue
351 WHERE processNo = ?";
352 $statement = WCF::getDB()->prepareStatement($sql);
353 $statement->execute([$this->queue->processNo]);
354 while ($row = $statement->fetchArray()) {
355 @unlink($row['archive']);
356 }
357
358 // delete queues
359 $sql = "DELETE FROM wcf".WCF_N."_package_installation_queue
360 WHERE processNo = ?";
361 $statement = WCF::getDB()->prepareStatement($sql);
362 $statement->execute([$this->queue->processNo]);
363
364 $this->logInstallationStep([], 'finished cleanup');
365 }
366
367 return $step;
368 }
369
370 /**
371 * Logs an installation step.
372 *
373 * @param array $node data of the executed node
374 * @param string $log optional additional log text
375 */
376 protected function logInstallationStep(array $node = [], $log = '') {
377 $logEntry = "[" . TIME_NOW . "]\n";
378 if (!empty($node)) {
379 $logEntry .= 'sequenceNo: ' . $node['sequenceNo'] . "\n";
380 $logEntry .= 'nodeType: ' . $node['nodeType'] . "\n";
381 $logEntry .= "nodeData:\n";
382
383 $nodeData = unserialize($node['nodeData']);
384 foreach ($nodeData as $index => $value) {
385 $logEntry .= "\t" . $index . ': ' . (!is_object($value) && !is_array($value) ? $value : JSON::encode($value)) . "\n";
386 }
387 }
388
389 if ($log) {
390 $logEntry .= 'additional information: ' . $log . "\n";
391 }
392
393 $logEntry .= str_repeat('-', 30) . "\n\n";
394
395 file_put_contents(
396 WCF_DIR . 'log/' . date('Y-m-d', TIME_NOW) . '-update-' . $this->queue->queueID . '.txt',
397 $logEntry,
398 FILE_APPEND
399 );
400 }
401
402 /**
403 * Returns current package archive.
404 *
405 * @return PackageArchive
406 */
407 public function getArchive() {
408 if ($this->archive === null) {
409 // check if we're doing an iterative update of the same package
410 if ($this->previousPackageData !== null && $this->getPackage()->package == $this->previousPackageData['package']) {
411 if (Package::compareVersion($this->getPackage()->packageVersion, $this->previousPackageData['packageVersion'], '<')) {
412 // fake package to simulate the package version required by current archive
413 $this->getPackage()->setPackageVersion($this->previousPackageData['packageVersion']);
414 }
415 }
416
417 $this->archive = new PackageArchive($this->queue->archive, $this->getPackage());
418
419 if (FileUtil::isURL($this->archive->getArchive())) {
420 // get return value and update entry in
421 // package_installation_queue with this value
422 $archive = $this->archive->downloadArchive();
423 $queueEditor = new PackageInstallationQueueEditor($this->queue);
424 $queueEditor->update(['archive' => $archive]);
425 }
426
427 $this->archive->openArchive();
428 }
429
430 return $this->archive;
431 }
432
433 /**
434 * Installs current package.
435 *
436 * @param mixed[] $nodeData
437 * @return PackageInstallationStep
438 * @throws SystemException
439 */
440 protected function installPackage(array $nodeData) {
441 $installationStep = new PackageInstallationStep();
442
443 // check requirements
444 if (!empty($nodeData['requirements'])) {
445 foreach ($nodeData['requirements'] as $package => $requirementData) {
446 // get existing package
447 if ($requirementData['packageID']) {
448 $sql = "SELECT packageName, packageVersion
449 FROM wcf".WCF_N."_package
450 WHERE packageID = ?";
451 $statement = WCF::getDB()->prepareStatement($sql);
452 $statement->execute([$requirementData['packageID']]);
453 }
454 else {
455 // try to find matching package
456 $sql = "SELECT packageName, packageVersion
457 FROM wcf".WCF_N."_package
458 WHERE package = ?";
459 $statement = WCF::getDB()->prepareStatement($sql);
460 $statement->execute([$package]);
461 }
462 $row = $statement->fetchArray();
463
464 // package is required but not available
465 if ($row === false) {
466 throw new SystemException("Package '".$package."' is required by '".$nodeData['packageName']."', but is neither installed nor shipped.");
467 }
468
469 // check version requirements
470 if ($requirementData['minVersion']) {
471 if (Package::compareVersion($row['packageVersion'], $requirementData['minVersion']) < 0) {
472 throw new SystemException("Package '".$nodeData['packageName']."' requires package '".$row['packageName']."' in version '".$requirementData['minVersion']."', but only version '".$row['packageVersion']."' is installed");
473 }
474 }
475 }
476 }
477 unset($nodeData['requirements']);
478
479 $applicationDirectory = '';
480 if (isset($nodeData['applicationDirectory'])) {
481 $applicationDirectory = $nodeData['applicationDirectory'];
482 unset($nodeData['applicationDirectory']);
483 }
484
485 // update package
486 if ($this->queue->packageID) {
487 $packageEditor = new PackageEditor(new Package($this->queue->packageID));
488 unset($nodeData['installDate']);
489 $packageEditor->update($nodeData);
490
491 // delete old excluded packages
492 $sql = "DELETE FROM wcf".WCF_N."_package_exclusion
493 WHERE packageID = ?";
494 $statement = WCF::getDB()->prepareStatement($sql);
495 $statement->execute([$this->queue->packageID]);
496
497 // delete old compatibility versions
498 $sql = "DELETE FROM wcf".WCF_N."_package_compatibility
499 WHERE packageID = ?";
500 $statement = WCF::getDB()->prepareStatement($sql);
501 $statement->execute([$this->queue->packageID]);
502
503 // delete old requirements and dependencies
504 $sql = "DELETE FROM wcf".WCF_N."_package_requirement
505 WHERE packageID = ?";
506 $statement = WCF::getDB()->prepareStatement($sql);
507 $statement->execute([$this->queue->packageID]);
508 }
509 else {
510 // create package entry
511 $package = $this->createPackage($nodeData);
512
513 // update package id for current queue
514 $queueEditor = new PackageInstallationQueueEditor($this->queue);
515 $queueEditor->update(['packageID' => $package->packageID]);
516
517 // reload queue
518 $this->queue = new PackageInstallationQueue($this->queue->queueID);
519 $this->package = null;
520
521 if ($package->isApplication) {
522 $host = str_replace(RouteHandler::getProtocol(), '', RouteHandler::getHost());
523 $path = RouteHandler::getPath(['acp']);
524
525 // insert as application
526 ApplicationEditor::create([
527 'domainName' => $host,
528 'domainPath' => $path,
529 'cookieDomain' => $host,
530 'packageID' => $package->packageID
531 ]);
532 }
533 }
534
535 // save excluded packages
536 if (count($this->getArchive()->getExcludedPackages())) {
537 $sql = "INSERT INTO wcf".WCF_N."_package_exclusion
538 (packageID, excludedPackage, excludedPackageVersion)
539 VALUES (?, ?, ?)";
540 $statement = WCF::getDB()->prepareStatement($sql);
541
542 foreach ($this->getArchive()->getExcludedPackages() as $excludedPackage) {
543 $statement->execute([
544 $this->queue->packageID,
545 $excludedPackage['name'],
546 !empty($excludedPackage['version']) ? $excludedPackage['version'] : ''
547 ]);
548 }
549 }
550
551 // save compatible versions
552 if (!empty($this->getArchive()->getCompatibleVersions())) {
553 $sql = "INSERT INTO wcf".WCF_N."_package_compatibility
554 (packageID, version)
555 VALUES (?, ?)";
556 $statement = WCF::getDB()->prepareStatement($sql);
557
558 foreach ($this->getArchive()->getCompatibleVersions() as $version) {
559 $statement->execute([
560 $this->queue->packageID,
561 $version
562 ]);
563 }
564 }
565
566 // insert requirements and dependencies
567 $requirements = $this->getArchive()->getAllExistingRequirements();
568 if (!empty($requirements)) {
569 $sql = "INSERT INTO wcf".WCF_N."_package_requirement
570 (packageID, requirement)
571 VALUES (?, ?)";
572 $statement = WCF::getDB()->prepareStatement($sql);
573
574 foreach ($requirements as $identifier => $possibleRequirements) {
575 $requirement = array_shift($possibleRequirements);
576
577 $statement->execute([
578 $this->queue->packageID,
579 $requirement['packageID']
580 ]);
581 }
582 }
583
584 if ($this->getPackage()->isApplication && $this->getPackage()->package != 'com.woltlab.wcf' && $this->getAction() == 'install' && empty($this->getPackage()->packageDir)) {
585 $document = $this->promptPackageDir($applicationDirectory);
586 if ($document !== null && $document instanceof FormDocument) {
587 $installationStep->setDocument($document);
588 }
589
590 $installationStep->setSplitNode();
591 }
592
593 return $installationStep;
594 }
595
596 /**
597 * Creates a new package based on the given data and returns it.
598 *
599 * @param array $packageData
600 * @return Package
601 * @since 5.2
602 */
603 protected function createPackage(array $packageData) {
604 return PackageEditor::create($packageData);
605 }
606
607 /**
608 * Saves the localized package info.
609 */
610 protected function saveLocalizedPackageInfos() {
611 $package = new Package($this->queue->packageID);
612
613 // localize package information
614 $sql = "INSERT INTO wcf".WCF_N."_language_item
615 (languageID, languageItem, languageItemValue, languageCategoryID, packageID)
616 VALUES (?, ?, ?, ?, ?)";
617 $statement = WCF::getDB()->prepareStatement($sql);
618
619 // get language list
620 $languageList = new LanguageList();
621 $languageList->readObjects();
622
623 // workaround for WCFSetup
624 if (!PACKAGE_ID) {
625 $sql = "SELECT *
626 FROM wcf".WCF_N."_language_category
627 WHERE languageCategory = ?";
628 $statement2 = WCF::getDB()->prepareStatement($sql);
629 $statement2->execute(['wcf.acp.package']);
630 $languageCategory = $statement2->fetchObject(LanguageCategory::class);
631 }
632 else {
633 $languageCategory = LanguageFactory::getInstance()->getCategory('wcf.acp.package');
634 }
635
636 // save package name
637 $this->saveLocalizedPackageInfo($statement, $languageList, $languageCategory, $package, 'packageName');
638
639 // save package description
640 $this->saveLocalizedPackageInfo($statement, $languageList, $languageCategory, $package, 'packageDescription');
641
642 // update description and name
643 $packageEditor = new PackageEditor($package);
644 $packageEditor->update([
645 'packageDescription' => 'wcf.acp.package.packageDescription.package'.$this->queue->packageID,
646 'packageName' => 'wcf.acp.package.packageName.package'.$this->queue->packageID
647 ]);
648 }
649
650 /**
651 * Saves a localized package info.
652 *
653 * @param PreparedStatement $statement
654 * @param LanguageList $languageList
655 * @param LanguageCategory $languageCategory
656 * @param Package $package
657 * @param string $infoName
658 */
659 protected function saveLocalizedPackageInfo(PreparedStatement $statement, $languageList, LanguageCategory $languageCategory, Package $package, $infoName) {
660 $infoValues = $this->getArchive()->getPackageInfo($infoName);
661
662 // get default value for languages without specified information
663 $defaultValue = '';
664 if (isset($infoValues['default'])) {
665 $defaultValue = $infoValues['default'];
666 }
667 else if (isset($infoValues['en'])) {
668 // fallback to English
669 $defaultValue = $infoValues['en'];
670 }
671 else if (isset($infoValues[WCF::getLanguage()->getFixedLanguageCode()])) {
672 // fallback to the language of the current user
673 $defaultValue = $infoValues[WCF::getLanguage()->getFixedLanguageCode()];
674 }
675 else if ($infoName == 'packageName') {
676 // fallback to the package identifier for the package name
677 $defaultValue = $this->getArchive()->getPackageInfo('name');
678 }
679
680 foreach ($languageList as $language) {
681 $value = $defaultValue;
682 if (isset($infoValues[$language->languageCode])) {
683 $value = $infoValues[$language->languageCode];
684 }
685
686 $statement->execute([
687 $language->languageID,
688 'wcf.acp.package.'.$infoName.'.package'.$package->packageID,
689 $value,
690 $languageCategory->languageCategoryID,
691 1
692 ]);
693 }
694 }
695
696 /**
697 * Executes a package installation plugin.
698 *
699 * @param mixed[] $nodeData
700 * @return PackageInstallationStep
701 * @throws SystemException
702 */
703 protected function executePIP(array $nodeData) {
704 $step = new PackageInstallationStep();
705
706 if ($nodeData['pip'] == PackageArchive::VOID_MARKER) {
707 return $step;
708 }
709
710 // fetch all pips associated with current PACKAGE_ID and include pips
711 // previously installed by current installation queue
712 $sql = "SELECT pluginName, className
713 FROM wcf".WCF_N."_package_installation_plugin
714 WHERE pluginName = ?";
715 $statement = WCF::getDB()->prepareStatement($sql);
716 $statement->execute([$nodeData['pip']]);
717 $row = $statement->fetchArray();
718
719 // PIP is unknown
720 if (!$row || (strcmp($nodeData['pip'], $row['pluginName']) !== 0)) {
721 throw new SystemException("unable to find package installation plugin '".$nodeData['pip']."'");
722 }
723
724 // valdidate class definition
725 $className = $row['className'];
726 if (!class_exists($className)) {
727 throw new SystemException("unable to find class '".$className."'");
728 }
729
730 // set default value
731 if (empty($nodeData['value'])) {
732 $defaultValue = call_user_func([$className, 'getDefaultFilename']);
733 if ($defaultValue) {
734 $nodeData['value'] = $defaultValue;
735 }
736 }
737
738 $plugin = new $className($this, $nodeData);
739
740 if (!($plugin instanceof IPackageInstallationPlugin)) {
741 throw new ImplementationException($className, IPackageInstallationPlugin::class);
742 }
743
744 // execute PIP
745 $document = null;
746 try {
747 $document = $plugin->{$this->action}();
748 }
749 catch (SplitNodeException $e) {
750 $step->setSplitNode($e);
751 }
752
753 if ($document !== null && ($document instanceof FormDocument)) {
754 $step->setDocument($document);
755 $step->setSplitNode();
756 }
757
758 return $step;
759 }
760
761 /**
762 * Displays a list to select optional packages or installs selection.
763 *
764 * @param string $currentNode
765 * @param array $nodeData
766 * @return PackageInstallationStep
767 */
768 protected function selectOptionalPackages($currentNode, array $nodeData) {
769 $installationStep = new PackageInstallationStep();
770
771 $document = $this->promptOptionalPackages($nodeData);
772 if ($document !== null && $document instanceof FormDocument) {
773 $installationStep->setDocument($document);
774 $installationStep->setSplitNode();
775 }
776 // insert new nodes for each package
777 else if (is_array($document)) {
778 // get target child node
779 $node = $currentNode;
780 $queue = $this->queue;
781 $shiftNodes = false;
782
783 foreach ($nodeData as $package) {
784 if (in_array($package['package'], $document)) {
785 // ignore uninstallable packages
786 if (!$package['isInstallable']) {
787 continue;
788 }
789
790 if (!$shiftNodes) {
791 $this->nodeBuilder->shiftNodes($currentNode, 'tempNode');
792 $shiftNodes = true;
793 }
794
795 $queue = PackageInstallationQueueEditor::create([
796 'parentQueueID' => $queue->queueID,
797 'processNo' => $this->queue->processNo,
798 'userID' => WCF::getUser()->userID,
799 'package' => $package['package'],
800 'packageName' => $package['packageName'],
801 'archive' => $package['archive'],
802 'action' => $queue->action
803 ]);
804
805 $installation = new PackageInstallationDispatcher($queue);
806 $installation->nodeBuilder->setParentNode($node);
807 $installation->nodeBuilder->buildNodes();
808 $node = $installation->nodeBuilder->getCurrentNode();
809 }
810 else {
811 // remove archive
812 @unlink($package['archive']);
813 }
814 }
815
816 // shift nodes
817 if ($shiftNodes) {
818 $this->nodeBuilder->shiftNodes('tempNode', $node);
819 }
820 }
821
822 return $installationStep;
823 }
824
825 /**
826 * Extracts files from .tar(.gz) archive and installs them
827 *
828 * @param string $targetDir
829 * @param string $sourceArchive
830 * @param IFileHandler $fileHandler
831 * @return Installer
832 */
833 public function extractFiles($targetDir, $sourceArchive, $fileHandler = null) {
834 return new Installer($targetDir, $sourceArchive, $fileHandler);
835 }
836
837 /**
838 * Returns current package.
839 *
840 * @return \wcf\data\package\Package
841 */
842 public function getPackage() {
843 if ($this->package === null) {
844 $this->package = new Package($this->queue->packageID);
845 }
846
847 return $this->package;
848 }
849
850 /**
851 * Prompts for a text input for package directory (applies for applications only)
852 *
853 * @param string $applicationDirectory
854 * @return FormDocument
855 */
856 protected function promptPackageDir($applicationDirectory) {
857 // check for pre-defined directories originating from WCFSetup
858 $directory = WCF::getSession()->getVar('__wcfSetup_directories');
859 $abbreviation = Package::getAbbreviation($this->getPackage()->package);
860 if ($directory !== null) {
861 $directory = $directory[$abbreviation] ?? null;
862 }
863 else if (ENABLE_ENTERPRISE_MODE && defined('ENTERPRISE_MODE_APP_DIRECTORIES') && is_array(ENTERPRISE_MODE_APP_DIRECTORIES)) {
864 $directory = ENTERPRISE_MODE_APP_DIRECTORIES[$abbreviation] ?? null;
865 }
866
867 if ($directory === null && !PackageInstallationFormManager::findForm($this->queue, 'packageDir')) {
868 $container = new GroupFormElementContainer();
869 $packageDir = new TextInputFormElement($container);
870 $packageDir->setName('packageDir');
871 $packageDir->setLabel(WCF::getLanguage()->get('wcf.acp.package.packageDir.input'));
872
873 // check if there are packages installed in a parent
874 // directory of WCF, or if packages are below it
875 $sql = "SELECT packageDir
876 FROM wcf".WCF_N."_package
877 WHERE packageDir <> ''";
878 $statement = WCF::getDB()->prepareStatement($sql);
879 $statement->execute();
880
881 $isParent = null;
882 while ($column = $statement->fetchColumn()) {
883 if ($isParent !== null) {
884 continue;
885 }
886
887 if (preg_match('~^\.\./[^\.]~', $column)) {
888 $isParent = false;
889 }
890 else if (mb_strpos($column, '.') !== 0) {
891 $isParent = true;
892 }
893 }
894
895 $defaultPath = WCF_DIR;
896 if ($isParent === false) {
897 $defaultPath = dirname(WCF_DIR);
898 }
899 if (!$applicationDirectory) {
900 $applicationDirectory = Package::getAbbreviation($this->getPackage()->package);
901 }
902 $defaultPath = FileUtil::addTrailingSlash(FileUtil::unifyDirSeparator($defaultPath)) . $applicationDirectory . '/';
903
904 $packageDir->setValue($defaultPath);
905 $container->appendChild($packageDir);
906
907 $document = new FormDocument('packageDir');
908 $document->appendContainer($container);
909
910 PackageInstallationFormManager::registerForm($this->queue, $document);
911 return $document;
912 }
913 else {
914 if ($directory !== null) {
915 $document = null;
916 $packageDir = $directory;
917 }
918 else {
919 $document = PackageInstallationFormManager::getForm($this->queue, 'packageDir');
920 $document->handleRequest();
921 $packageDir = FileUtil::addTrailingSlash(FileUtil::getRealPath(FileUtil::unifyDirSeparator($document->getValue('packageDir'))));
922 if ($packageDir === '/') $packageDir = '';
923 }
924
925 if ($packageDir !== null) {
926 // validate package dir
927 if ($document !== null && file_exists($packageDir . 'global.php')) {
928 $document->setError('packageDir', WCF::getLanguage()->get('wcf.acp.package.packageDir.notAvailable'));
929 return $document;
930 }
931
932 // set package dir
933 $packageEditor = new PackageEditor($this->getPackage());
934 $packageEditor->update([
935 'packageDir' => FileUtil::getRelativePath(WCF_DIR, $packageDir)
936 ]);
937
938 // determine domain path, in some environments (e.g. ISPConfig) the $_SERVER paths are
939 // faked and differ from the real filesystem path
940 if (PACKAGE_ID) {
941 $wcfDomainPath = ApplicationHandler::getInstance()->getWCF()->domainPath;
942 }
943 else {
944 $sql = "SELECT domainPath
945 FROM wcf".WCF_N."_application
946 WHERE packageID = ?";
947 $statement = WCF::getDB()->prepareStatement($sql);
948 $statement->execute([1]);
949 $row = $statement->fetchArray();
950
951 $wcfDomainPath = $row['domainPath'];
952 }
953
954 $documentRoot = substr(FileUtil::unifyDirSeparator(WCF_DIR), 0, -strlen(FileUtil::unifyDirSeparator($wcfDomainPath)));
955 $domainPath = FileUtil::getRelativePath($documentRoot, $packageDir);
956 if ($domainPath === './') {
957 // `FileUtil::getRelativePath()` returns `./` if both paths lead to the same directory
958 $domainPath = '/';
959 }
960
961 $domainPath = FileUtil::addLeadingSlash($domainPath);
962
963 // update application path
964 $application = new Application($this->getPackage()->packageID);
965 $applicationEditor = new ApplicationEditor($application);
966 $applicationEditor->update(['domainPath' => $domainPath]);
967
968 // create directory and set permissions
969 @mkdir($packageDir, 0777, true);
970 FileUtil::makeWritable($packageDir);
971 }
972
973 return null;
974 }
975 }
976
977 /**
978 * Prompts a selection of optional packages.
979 *
980 * @param string[][] $packages
981 * @return mixed
982 */
983 protected function promptOptionalPackages(array $packages) {
984 if (!PackageInstallationFormManager::findForm($this->queue, 'optionalPackages')) {
985 $container = new MultipleSelectionFormElementContainer();
986 $container->setName('optionalPackages');
987 $container->setLabel(WCF::getLanguage()->get('wcf.acp.package.optionalPackages'));
988 $container->setDescription(WCF::getLanguage()->get('wcf.acp.package.optionalPackages.description'));
989
990 foreach ($packages as $package) {
991 $optionalPackage = new MultipleSelectionFormElement($container);
992 $optionalPackage->setName('optionalPackages');
993 $optionalPackage->setLabel($package['packageName']);
994 $optionalPackage->setValue($package['package']);
995 $optionalPackage->setDescription($package['packageDescription']);
996 if (!$package['isInstallable']) {
997 $optionalPackage->setDisabledMessage(WCF::getLanguage()->get('wcf.acp.package.install.optionalPackage.missingRequirements'));
998 }
999
1000 $container->appendChild($optionalPackage);
1001 }
1002
1003 $document = new FormDocument('optionalPackages');
1004 $document->appendContainer($container);
1005
1006 PackageInstallationFormManager::registerForm($this->queue, $document);
1007 return $document;
1008 }
1009 else {
1010 $document = PackageInstallationFormManager::getForm($this->queue, 'optionalPackages');
1011 $document->handleRequest();
1012
1013 return $document->getValue('optionalPackages');
1014 }
1015 }
1016
1017 /**
1018 * Returns current package id.
1019 *
1020 * @return integer
1021 */
1022 public function getPackageID() {
1023 return $this->queue->packageID;
1024 }
1025
1026 /**
1027 * Returns current package name.
1028 *
1029 * @return string package name
1030 * @since 3.0
1031 */
1032 public function getPackageName() {
1033 return $this->queue->packageName;
1034 }
1035
1036 /**
1037 * Returns current package installation type.
1038 *
1039 * @return string
1040 */
1041 public function getAction() {
1042 return $this->action;
1043 }
1044
1045 /**
1046 * Opens the package installation queue and
1047 * starts the installation, update or uninstallation of the first entry.
1048 *
1049 * @param integer $parentQueueID
1050 * @param integer $processNo
1051 */
1052 public static function openQueue($parentQueueID = 0, $processNo = 0) {
1053 $conditions = new PreparedStatementConditionBuilder();
1054 $conditions->add("userID = ?", [WCF::getUser()->userID]);
1055 $conditions->add("parentQueueID = ?", [$parentQueueID]);
1056 if ($processNo != 0) $conditions->add("processNo = ?", [$processNo]);
1057 $conditions->add("done = ?", [0]);
1058
1059 $sql = "SELECT *
1060 FROM wcf".WCF_N."_package_installation_queue
1061 ".$conditions."
1062 ORDER BY queueID ASC";
1063 $statement = WCF::getDB()->prepareStatement($sql);
1064 $statement->execute($conditions->getParameters());
1065 $packageInstallation = $statement->fetchArray();
1066
1067 if (!isset($packageInstallation['queueID'])) {
1068 $url = LinkHandler::getInstance()->getLink('PackageList');
1069 HeaderUtil::redirect($url);
1070 exit;
1071 }
1072 else {
1073 $url = LinkHandler::getInstance()->getLink('PackageInstallationConfirm', [], 'queueID='.$packageInstallation['queueID']);
1074 HeaderUtil::redirect($url);
1075 exit;
1076 }
1077 }
1078
1079 /**
1080 * Checks the package installation queue for outstanding entries.
1081 *
1082 * @return integer
1083 */
1084 public static function checkPackageInstallationQueue() {
1085 $sql = "SELECT queueID
1086 FROM wcf".WCF_N."_package_installation_queue
1087 WHERE userID = ?
1088 AND parentQueueID = 0
1089 AND done = 0
1090 ORDER BY queueID ASC";
1091 $statement = WCF::getDB()->prepareStatement($sql);
1092 $statement->execute([WCF::getUser()->userID]);
1093 $row = $statement->fetchArray();
1094
1095 if (!$row) {
1096 return 0;
1097 }
1098
1099 return $row['queueID'];
1100 }
1101
1102 /**
1103 * Executes post-setup actions.
1104 */
1105 public function completeSetup() {
1106 // remove archives
1107 $sql = "SELECT archive
1108 FROM wcf".WCF_N."_package_installation_queue
1109 WHERE processNo = ?";
1110 $statement = WCF::getDB()->prepareStatement($sql);
1111 $statement->execute([$this->queue->processNo]);
1112 while ($row = $statement->fetchArray()) {
1113 @unlink($row['archive']);
1114 }
1115
1116 // delete queues
1117 $sql = "DELETE FROM wcf".WCF_N."_package_installation_queue
1118 WHERE processNo = ?";
1119 $statement = WCF::getDB()->prepareStatement($sql);
1120 $statement->execute([$this->queue->processNo]);
1121
1122 // clear language files once whole installation is completed
1123 LanguageEditor::deleteLanguageFiles();
1124
1125 // reset all caches
1126 CacheHandler::getInstance()->flushAll();
1127 }
1128
1129 /**
1130 * Updates queue information.
1131 */
1132 public function updatePackage() {
1133 if (empty($this->queue->packageName)) {
1134 $queueEditor = new PackageInstallationQueueEditor($this->queue);
1135 $queueEditor->update([
1136 'packageName' => $this->getArchive()->getLocalizedPackageInfo('packageName')
1137 ]);
1138
1139 // reload queue
1140 $this->queue = new PackageInstallationQueue($this->queue->queueID);
1141 }
1142 }
1143
1144 /**
1145 * Validates specific php requirements.
1146 *
1147 * @param array $requirements
1148 * @return mixed[][]
1149 */
1150 public static function validatePHPRequirements(array $requirements) {
1151 $errors = [];
1152
1153 // validate php version
1154 if (isset($requirements['version'])) {
1155 $passed = false;
1156 if (version_compare(PHP_VERSION, $requirements['version'], '>=')) {
1157 $passed = true;
1158 }
1159
1160 if (!$passed) {
1161 $errors['version'] = [
1162 'required' => $requirements['version'],
1163 'installed' => PHP_VERSION
1164 ];
1165 }
1166 }
1167
1168 // validate extensions
1169 if (isset($requirements['extensions'])) {
1170 foreach ($requirements['extensions'] as $extension) {
1171 $passed = extension_loaded($extension) ? true : false;
1172
1173 if (!$passed) {
1174 $errors['extension'][] = [
1175 'extension' => $extension
1176 ];
1177 }
1178 }
1179 }
1180
1181 // validate settings
1182 if (isset($requirements['settings'])) {
1183 foreach ($requirements['settings'] as $setting => $value) {
1184 $iniValue = ini_get($setting);
1185
1186 $passed = self::compareSetting($setting, $value, $iniValue);
1187 if (!$passed) {
1188 $errors['setting'][] = [
1189 'setting' => $setting,
1190 'required' => $value,
1191 'installed' => ($iniValue === false) ? '(unknown)' : $iniValue
1192 ];
1193 }
1194 }
1195 }
1196
1197 // validate functions
1198 if (isset($requirements['functions'])) {
1199 foreach ($requirements['functions'] as $function) {
1200 $function = mb_strtolower($function);
1201
1202 $passed = self::functionExists($function);
1203 if (!$passed) {
1204 $errors['function'][] = [
1205 'function' => $function
1206 ];
1207 }
1208 }
1209 }
1210
1211 // validate classes
1212 if (isset($requirements['classes'])) {
1213 foreach ($requirements['classes'] as $class) {
1214 $passed = false;
1215
1216 // see: http://de.php.net/manual/en/language.oop5.basic.php
1217 if (preg_match('~[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*.~', $class)) {
1218 $globalClass = '\\'.$class;
1219
1220 if (class_exists($globalClass, false)) {
1221 $passed = true;
1222 }
1223 }
1224
1225 if (!$passed) {
1226 $errors['class'][] = [
1227 'class' => $class
1228 ];
1229 }
1230 }
1231 }
1232
1233 return $errors;
1234 }
1235
1236 /**
1237 * Validates if an function exists and is not blacklisted by suhosin extension.
1238 *
1239 * @param string $function
1240 * @return boolean
1241 * @see http://de.php.net/manual/en/function.function-exists.php#77980
1242 */
1243 protected static function functionExists($function) {
1244 if (extension_loaded('suhosin')) {
1245 $blacklist = @ini_get('suhosin.executor.func.blacklist');
1246 if (!empty($blacklist)) {
1247 $blacklist = explode(',', $blacklist);
1248 foreach ($blacklist as $disabledFunction) {
1249 $disabledFunction = mb_strtolower(StringUtil::trim($disabledFunction));
1250
1251 if ($function == $disabledFunction) {
1252 return false;
1253 }
1254 }
1255 }
1256 }
1257
1258 return function_exists($function);
1259 }
1260
1261 /**
1262 * Compares settings, converting values into compareable ones.
1263 *
1264 * @param string $setting
1265 * @param string $value
1266 * @param mixed $compareValue
1267 * @return boolean
1268 */
1269 protected static function compareSetting($setting, $value, $compareValue) {
1270 if ($compareValue === false) return false;
1271
1272 $value = mb_strtolower($value);
1273 $trueValues = ['1', 'on', 'true'];
1274 $falseValues = ['0', 'off', 'false'];
1275
1276 // handle values considered as 'true'
1277 if (in_array($value, $trueValues)) {
1278 return $compareValue ? true : false;
1279 }
1280 // handle values considered as 'false'
1281 else if (in_array($value, $falseValues)) {
1282 return (!$compareValue) ? true : false;
1283 }
1284 else if (!is_numeric($value)) {
1285 $compareValue = self::convertShorthandByteValue($compareValue);
1286 $value = self::convertShorthandByteValue($value);
1287 }
1288
1289 return ($compareValue >= $value) ? true : false;
1290 }
1291
1292 /**
1293 * Converts shorthand byte values into an integer representing bytes.
1294 *
1295 * @param string $value
1296 * @return integer
1297 * @see http://www.php.net/manual/en/faq.using.php#faq.using.shorthandbytes
1298 */
1299 protected static function convertShorthandByteValue($value) {
1300 // convert into bytes
1301 $lastCharacter = mb_substr($value, -1);
1302 switch ($lastCharacter) {
1303 // gigabytes
1304 case 'g':
1305 return (int)$value * 1073741824;
1306 break;
1307
1308 // megabytes
1309 case 'm':
1310 return (int)$value * 1048576;
1311 break;
1312
1313 // kilobytes
1314 case 'k':
1315 return (int)$value * 1024;
1316 break;
1317
1318 default:
1319 return $value;
1320 break;
1321 }
1322 }
1323 }