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