1<?php 2/** 3 * Base code for MediaWiki installer. 4 * 5 * DO NOT PATCH THIS FILE IF YOU NEED TO CHANGE INSTALLER BEHAVIOR IN YOUR PACKAGE! 6 * See mw-config/overrides/README for details. 7 * 8 * This program is free software; you can redistribute it and/or modify 9 * it under the terms of the GNU General Public License as published by 10 * the Free Software Foundation; either version 2 of the License, or 11 * (at your option) any later version. 12 * 13 * This program is distributed in the hope that it will be useful, 14 * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 * GNU General Public License for more details. 17 * 18 * You should have received a copy of the GNU General Public License along 19 * with this program; if not, write to the Free Software Foundation, Inc., 20 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 21 * http://www.gnu.org/copyleft/gpl.html 22 * 23 * @file 24 * @ingroup Installer 25 */ 26 27use MediaWiki\HookContainer\HookContainer; 28use MediaWiki\HookContainer\StaticHookRegistry; 29use MediaWiki\Interwiki\NullInterwikiLookup; 30use MediaWiki\MediaWikiServices; 31use MediaWiki\Shell\Shell; 32 33/** 34 * The Installer helps admins create or upgrade their wiki. 35 * 36 * The installer classes are exposed through these human interfaces: 37 * 38 * - The `maintenance/install.php` script, backed by CliInstaller. 39 * - The `maintenance/update.php` script, backed by DatabaseUpdater. 40 * - The `mw-config/index.php` web entry point, backed by WebInstaller. 41 * 42 * @defgroup Installer Installer 43 */ 44 45/** 46 * Base installer class. 47 * 48 * This class provides the base for installation and update functionality 49 * for both MediaWiki core and extensions. 50 * 51 * @ingroup Installer 52 * @since 1.17 53 */ 54abstract class Installer { 55 56 /** 57 * The oldest version of PCRE we can support. 58 * 59 * Defining this is necessary because PHP may be linked with a system version 60 * of PCRE, which may be older than that bundled with the minimum PHP version. 61 */ 62 public const MINIMUM_PCRE_VERSION = '7.2'; 63 64 /** 65 * URL to mediawiki-announce list summary page 66 */ 67 private const MEDIAWIKI_ANNOUNCE_URL = 68 'https://lists.wikimedia.org/postorius/lists/mediawiki-announce.lists.wikimedia.org/'; 69 70 /** 71 * @var array 72 */ 73 protected $settings; 74 75 /** 76 * List of detected DBs, access using getCompiledDBs(). 77 * 78 * @var array 79 */ 80 protected $compiledDBs; 81 82 /** 83 * Cached DB installer instances, access using getDBInstaller(). 84 * 85 * @var array 86 */ 87 protected $dbInstallers = []; 88 89 /** 90 * Minimum memory size in MiB. 91 * 92 * @var int 93 */ 94 protected $minMemorySize = 50; 95 96 /** 97 * Cached Title, used by parse(). 98 * 99 * @var Title 100 */ 101 protected $parserTitle; 102 103 /** 104 * Cached ParserOptions, used by parse(). 105 * 106 * @var ParserOptions 107 */ 108 protected $parserOptions; 109 110 /** 111 * Known database types. These correspond to the class names <type>Installer, 112 * and are also MediaWiki database types valid for $wgDBtype. 113 * 114 * To add a new type, create a <type>Installer class and a Database<type> 115 * class, and add a config-type-<type> message to MessagesEn.php. 116 * 117 * @var array 118 */ 119 protected static $dbTypes = [ 120 'mysql', 121 'postgres', 122 'sqlite', 123 ]; 124 125 /** 126 * A list of environment check methods called by doEnvironmentChecks(). 127 * These may output warnings using showMessage(), and/or abort the 128 * installation process by returning false. 129 * 130 * For the WebInstaller these are only called on the Welcome page, 131 * if these methods have side-effects that should affect later page loads 132 * (as well as the generated stylesheet), use envPreps instead. 133 * 134 * @var array 135 */ 136 protected $envChecks = [ 137 'envCheckDB', 138 'envCheckPCRE', 139 'envCheckMemory', 140 'envCheckCache', 141 'envCheckModSecurity', 142 'envCheckDiff3', 143 'envCheckGraphics', 144 'envCheckGit', 145 'envCheckServer', 146 'envCheckPath', 147 'envCheckShellLocale', 148 'envCheckUploadsDirectory', 149 'envCheckLibicu', 150 'envCheckSuhosinMaxValueLength', 151 'envCheck64Bit', 152 ]; 153 154 /** 155 * A list of environment preparation methods called by doEnvironmentPreps(). 156 * 157 * @var array 158 */ 159 protected $envPreps = [ 160 'envPrepServer', 161 'envPrepPath', 162 ]; 163 164 /** 165 * MediaWiki configuration globals that will eventually be passed through 166 * to LocalSettings.php. The names only are given here, the defaults 167 * typically come from DefaultSettings.php. 168 * 169 * @var array 170 */ 171 protected $defaultVarNames = [ 172 'wgSitename', 173 'wgPasswordSender', 174 'wgLanguageCode', 175 'wgLocaltimezone', 176 'wgRightsIcon', 177 'wgRightsText', 178 'wgRightsUrl', 179 'wgEnableEmail', 180 'wgEnableUserEmail', 181 'wgEnotifUserTalk', 182 'wgEnotifWatchlist', 183 'wgEmailAuthentication', 184 'wgDBname', 185 'wgDBtype', 186 'wgDiff3', 187 'wgImageMagickConvertCommand', 188 'wgGitBin', 189 'IP', 190 'wgScriptPath', 191 'wgMetaNamespace', 192 'wgDeletedDirectory', 193 'wgEnableUploads', 194 'wgShellLocale', 195 'wgSecretKey', 196 'wgUseInstantCommons', 197 'wgUpgradeKey', 198 'wgDefaultSkin', 199 'wgPingback', 200 ]; 201 202 /** 203 * Variables that are stored alongside globals, and are used for any 204 * configuration of the installation process aside from the MediaWiki 205 * configuration. Map of names to defaults. 206 * 207 * @var array 208 */ 209 protected $internalDefaults = [ 210 '_UserLang' => 'en', 211 '_Environment' => false, 212 '_RaiseMemory' => false, 213 '_UpgradeDone' => false, 214 '_InstallDone' => false, 215 '_Caches' => [], 216 '_InstallPassword' => '', 217 '_SameAccount' => true, 218 '_CreateDBAccount' => false, 219 '_NamespaceType' => 'site-name', 220 '_AdminName' => '', // will be set later, when the user selects language 221 '_AdminPassword' => '', 222 '_AdminPasswordConfirm' => '', 223 '_AdminEmail' => '', 224 '_Subscribe' => false, 225 '_SkipOptional' => 'continue', 226 '_RightsProfile' => 'wiki', 227 '_LicenseCode' => 'none', 228 '_CCDone' => false, 229 '_Extensions' => [], 230 '_Skins' => [], 231 '_MemCachedServers' => '', 232 '_UpgradeKeySupplied' => false, 233 '_ExistingDBSettings' => false, 234 // Single quotes are intentional, LocalSettingsGenerator must output this unescaped. 235 '_Logo' => '$wgResourceBasePath/resources/assets/wiki.png', 236 237 'wgAuthenticationTokenVersion' => 1, 238 ]; 239 240 /** 241 * The actual list of installation steps. This will be initialized by getInstallSteps() 242 * 243 * @var array[] 244 * @phan-var array<int,array{name:string,callback:array{0:object,1:string}}> 245 */ 246 private $installSteps = []; 247 248 /** 249 * Extra steps for installation, for things like DatabaseInstallers to modify 250 * 251 * @var array 252 */ 253 protected $extraInstallSteps = []; 254 255 /** 256 * Known object cache types and the functions used to test for their existence. 257 * 258 * @var array 259 */ 260 protected $objectCaches = [ 261 'apcu' => 'apcu_fetch', 262 'wincache' => 'wincache_ucache_get' 263 ]; 264 265 /** 266 * User rights profiles. 267 * 268 * @var array 269 */ 270 public $rightsProfiles = [ 271 'wiki' => [], 272 'no-anon' => [ 273 '*' => [ 'edit' => false ] 274 ], 275 'fishbowl' => [ 276 '*' => [ 277 'createaccount' => false, 278 'edit' => false, 279 ], 280 ], 281 'private' => [ 282 '*' => [ 283 'createaccount' => false, 284 'edit' => false, 285 'read' => false, 286 ], 287 ], 288 ]; 289 290 /** 291 * License types. 292 * 293 * @var array 294 */ 295 public $licenses = [ 296 'cc-by' => [ 297 'url' => 'https://creativecommons.org/licenses/by/4.0/', 298 'icon' => '$wgResourceBasePath/resources/assets/licenses/cc-by.png', 299 ], 300 'cc-by-sa' => [ 301 'url' => 'https://creativecommons.org/licenses/by-sa/4.0/', 302 'icon' => '$wgResourceBasePath/resources/assets/licenses/cc-by-sa.png', 303 ], 304 'cc-by-nc-sa' => [ 305 'url' => 'https://creativecommons.org/licenses/by-nc-sa/4.0/', 306 'icon' => '$wgResourceBasePath/resources/assets/licenses/cc-by-nc-sa.png', 307 ], 308 'cc-0' => [ 309 'url' => 'https://creativecommons.org/publicdomain/zero/1.0/', 310 'icon' => '$wgResourceBasePath/resources/assets/licenses/cc-0.png', 311 ], 312 'gfdl' => [ 313 'url' => 'https://www.gnu.org/copyleft/fdl.html', 314 'icon' => '$wgResourceBasePath/resources/assets/licenses/gnu-fdl.png', 315 ], 316 'none' => [ 317 'url' => '', 318 'icon' => '', 319 'text' => '' 320 ], 321 'cc-choose' => [ 322 // Details will be filled in by the selector. 323 'url' => '', 324 'icon' => '', 325 'text' => '', 326 ], 327 ]; 328 329 /** 330 * @var HookContainer|null 331 */ 332 protected $autoExtensionHookContainer; 333 334 /** 335 * UI interface for displaying a short message 336 * The parameters are like parameters to wfMessage(). 337 * The messages will be in wikitext format, which will be converted to an 338 * output format such as HTML or text before being sent to the user. 339 * @param string $msg 340 * @param mixed ...$params 341 */ 342 abstract public function showMessage( $msg, ...$params ); 343 344 /** 345 * Same as showMessage(), but for displaying errors 346 * @param string $msg 347 * @param mixed ...$params 348 */ 349 abstract public function showError( $msg, ...$params ); 350 351 /** 352 * Show a message to the installing user by using a Status object 353 * @param Status $status 354 */ 355 abstract public function showStatusMessage( Status $status ); 356 357 /** 358 * Constructs a Config object that contains configuration settings that should be 359 * overwritten for the installation process. 360 * 361 * @since 1.27 362 * 363 * @param Config $baseConfig 364 * 365 * @return Config The config to use during installation. 366 */ 367 public static function getInstallerConfig( Config $baseConfig ) { 368 $configOverrides = new HashConfig(); 369 370 // disable (problematic) object cache types explicitly, preserving all other (working) ones 371 // bug T113843 372 $emptyCache = [ 'class' => EmptyBagOStuff::class ]; 373 374 $objectCaches = [ 375 CACHE_NONE => $emptyCache, 376 CACHE_DB => $emptyCache, 377 CACHE_ANYTHING => $emptyCache, 378 CACHE_MEMCACHED => $emptyCache, 379 ] + $baseConfig->get( 'ObjectCaches' ); 380 381 $configOverrides->set( 'ObjectCaches', $objectCaches ); 382 383 // Load the installer's i18n. 384 $messageDirs = $baseConfig->get( 'MessagesDirs' ); 385 $messageDirs['MediawikiInstaller'] = __DIR__ . '/i18n'; 386 387 $configOverrides->set( 'MessagesDirs', $messageDirs ); 388 389 $installerConfig = new MultiConfig( [ $configOverrides, $baseConfig ] ); 390 391 // make sure we use the installer config as the main config 392 $configRegistry = $baseConfig->get( 'ConfigRegistry' ); 393 $configRegistry['main'] = static function () use ( $installerConfig ) { 394 return $installerConfig; 395 }; 396 397 $configOverrides->set( 'ConfigRegistry', $configRegistry ); 398 399 return $installerConfig; 400 } 401 402 /** 403 * Constructor, always call this from child classes. 404 */ 405 public function __construct() { 406 $defaultConfig = new GlobalVarConfig(); // all the stuff from DefaultSettings.php 407 $installerConfig = self::getInstallerConfig( $defaultConfig ); 408 409 $this->resetMediaWikiServices( $installerConfig ); 410 411 // Disable all storage services, since we don't have any configuration yet! 412 MediaWikiServices::disableStorageBackend(); 413 414 $this->settings = $this->internalDefaults; 415 416 foreach ( $this->defaultVarNames as $var ) { 417 $this->settings[$var] = $GLOBALS[$var]; 418 } 419 420 $this->doEnvironmentPreps(); 421 422 $this->compiledDBs = []; 423 foreach ( self::getDBTypes() as $type ) { 424 $installer = $this->getDBInstaller( $type ); 425 426 if ( !$installer->isCompiled() ) { 427 continue; 428 } 429 $this->compiledDBs[] = $type; 430 } 431 432 $this->parserTitle = Title::newFromText( 'Installer' ); 433 } 434 435 /** 436 * Reset the global service container and associated global state 437 * to accommodate different stages of the installation. 438 * @since 1.35 439 * 440 * @param Config|null $installerConfig Config override. If null, the previous 441 * config will be inherited. 442 * @param array $serviceOverrides Service definition overrides. Values can be null to 443 * disable specific overrides that would be applied per default, namely 444 * 'InterwikiLookup' and 'UserOptionsLookup'. 445 * 446 * @return MediaWikiServices 447 * @throws MWException 448 */ 449 public function resetMediaWikiServices( Config $installerConfig = null, $serviceOverrides = [] ) { 450 global $wgObjectCaches, $wgLang; 451 452 $serviceOverrides += [ 453 // Disable interwiki lookup, to avoid database access during parses 454 'InterwikiLookup' => static function () { 455 return new NullInterwikiLookup(); 456 }, 457 458 // Disable user options database fetching, only rely on default options. 459 'UserOptionsLookup' => static function ( MediaWikiServices $services ) { 460 return $services->get( '_DefaultOptionsLookup' ); 461 } 462 ]; 463 464 $lang = $this->getVar( '_UserLang', 'en' ); 465 466 // Reset all services and inject config overrides 467 MediaWikiServices::resetGlobalInstance( $installerConfig ); 468 469 $mwServices = MediaWikiServices::getInstance(); 470 471 foreach ( $serviceOverrides as $name => $callback ) { 472 // Skip if the caller set $callback to null 473 // to suppress default overrides. 474 if ( $callback ) { 475 $mwServices->redefineService( $name, $callback ); 476 } 477 } 478 479 // Disable i18n cache 480 $mwServices->getLocalisationCache()->disableBackend(); 481 482 // Set a fake user. 483 // Note that this will reset the context's language, 484 // so set the user before setting the language. 485 $user = User::newFromId( 0 ); 486 StubGlobalUser::setUser( $user ); 487 488 RequestContext::getMain()->setUser( $user ); 489 490 // Don't attempt to load user language options (T126177) 491 // This will be overridden in the web installer with the user-specified language 492 // Ensure $wgLang does not have a reference to a stale LocalisationCache instance 493 // (T241638, T261081) 494 RequestContext::getMain()->setLanguage( $lang ); 495 $wgLang = RequestContext::getMain()->getLanguage(); 496 497 // Disable object cache (otherwise CACHE_ANYTHING will try CACHE_DB and 498 // SqlBagOStuff will then throw since we just disabled wfGetDB) 499 $wgObjectCaches = $mwServices->getMainConfig()->get( 'ObjectCaches' ); 500 501 $this->parserOptions = new ParserOptions( $user ); // language will be wrong :( 502 // Don't try to access DB before user language is initialised 503 $this->setParserLanguage( $mwServices->getLanguageFactory()->getLanguage( 'en' ) ); 504 505 return $mwServices; 506 } 507 508 /** 509 * Get a list of known DB types. 510 * 511 * @return array 512 */ 513 public static function getDBTypes() { 514 return self::$dbTypes; 515 } 516 517 /** 518 * Do initial checks of the PHP environment. Set variables according to 519 * the observed environment. 520 * 521 * It's possible that this may be called under the CLI SAPI, not the SAPI 522 * that the wiki will primarily run under. In that case, the subclass should 523 * initialise variables such as wgScriptPath, before calling this function. 524 * 525 * Under the web subclass, it can already be assumed that PHP 5+ is in use 526 * and that sessions are working. 527 * 528 * @return Status 529 */ 530 public function doEnvironmentChecks() { 531 // PHP version has already been checked by entry scripts 532 // Show message here for information purposes 533 $this->showMessage( 'config-env-php', PHP_VERSION ); 534 535 $good = true; 536 // Must go here because an old version of PCRE can prevent other checks from completing 537 $pcreVersion = explode( ' ', PCRE_VERSION, 2 )[0]; 538 if ( version_compare( $pcreVersion, self::MINIMUM_PCRE_VERSION, '<' ) ) { 539 $this->showError( 'config-pcre-old', self::MINIMUM_PCRE_VERSION, $pcreVersion ); 540 $good = false; 541 } else { 542 foreach ( $this->envChecks as $check ) { 543 $status = $this->$check(); 544 if ( $status === false ) { 545 $good = false; 546 } 547 } 548 } 549 550 $this->setVar( '_Environment', $good ); 551 552 return $good ? Status::newGood() : Status::newFatal( 'config-env-bad' ); 553 } 554 555 public function doEnvironmentPreps() { 556 foreach ( $this->envPreps as $prep ) { 557 $this->$prep(); 558 } 559 } 560 561 /** 562 * Set a MW configuration variable, or internal installer configuration variable. 563 * 564 * @param string $name 565 * @param mixed $value 566 */ 567 public function setVar( $name, $value ) { 568 $this->settings[$name] = $value; 569 } 570 571 /** 572 * Get an MW configuration variable, or internal installer configuration variable. 573 * The defaults come from $GLOBALS (ultimately DefaultSettings.php). 574 * Installer variables are typically prefixed by an underscore. 575 * 576 * @param string $name 577 * @param mixed|null $default 578 * 579 * @return mixed 580 */ 581 public function getVar( $name, $default = null ) { 582 return $this->settings[$name] ?? $default; 583 } 584 585 /** 586 * Get a list of DBs supported by current PHP setup 587 * 588 * @return array 589 */ 590 public function getCompiledDBs() { 591 return $this->compiledDBs; 592 } 593 594 /** 595 * Get the DatabaseInstaller class name for this type 596 * 597 * @param string $type database type ($wgDBtype) 598 * @return string Class name 599 * @since 1.30 600 */ 601 public static function getDBInstallerClass( $type ) { 602 return ucfirst( $type ) . 'Installer'; 603 } 604 605 /** 606 * Get an instance of DatabaseInstaller for the specified DB type. 607 * 608 * @param mixed $type DB installer for which is needed, false to use default. 609 * 610 * @return DatabaseInstaller 611 */ 612 public function getDBInstaller( $type = false ) { 613 if ( !$type ) { 614 $type = $this->getVar( 'wgDBtype' ); 615 } 616 617 $type = strtolower( $type ); 618 619 if ( !isset( $this->dbInstallers[$type] ) ) { 620 $class = self::getDBInstallerClass( $type ); 621 $this->dbInstallers[$type] = new $class( $this ); 622 } 623 624 return $this->dbInstallers[$type]; 625 } 626 627 /** 628 * Determine if LocalSettings.php exists. If it does, return its variables. 629 * 630 * @return array|false 631 */ 632 public static function getExistingLocalSettings() { 633 global $IP; 634 635 // You might be wondering why this is here. Well if you don't do this 636 // then some poorly-formed extensions try to call their own classes 637 // after immediately registering them. We really need to get extension 638 // registration out of the global scope and into a real format. 639 // @see https://phabricator.wikimedia.org/T69440 640 global $wgAutoloadClasses; 641 $wgAutoloadClasses = []; 642 643 // LocalSettings.php should not call functions, except wfLoadSkin/wfLoadExtensions 644 // Define the required globals here, to ensure, the functions can do it work correctly. 645 // phpcs:ignore MediaWiki.VariableAnalysis.UnusedGlobalVariables 646 global $wgExtensionDirectory, $wgStyleDirectory; 647 648 Wikimedia\suppressWarnings(); 649 $_lsExists = file_exists( "$IP/LocalSettings.php" ); 650 Wikimedia\restoreWarnings(); 651 652 if ( !$_lsExists ) { 653 return false; 654 } 655 unset( $_lsExists ); 656 657 require "$IP/includes/DefaultSettings.php"; 658 require "$IP/LocalSettings.php"; 659 660 return get_defined_vars(); 661 } 662 663 /** 664 * Get a fake password for sending back to the user in HTML. 665 * This is a security mechanism to avoid compromise of the password in the 666 * event of session ID compromise. 667 * 668 * @param string $realPassword 669 * 670 * @return string 671 */ 672 public function getFakePassword( $realPassword ) { 673 return str_repeat( '*', strlen( $realPassword ) ); 674 } 675 676 /** 677 * Set a variable which stores a password, except if the new value is a 678 * fake password in which case leave it as it is. 679 * 680 * @param string $name 681 * @param mixed $value 682 */ 683 public function setPassword( $name, $value ) { 684 if ( !preg_match( '/^\*+$/', $value ) ) { 685 $this->setVar( $name, $value ); 686 } 687 } 688 689 /** 690 * On POSIX systems return the primary group of the webserver we're running under. 691 * On other systems just returns null. 692 * 693 * This is used to advice the user that he should chgrp his mw-config/data/images directory as the 694 * webserver user before he can install. 695 * 696 * Public because SqliteInstaller needs it, and doesn't subclass Installer. 697 * 698 * @return mixed 699 */ 700 public static function maybeGetWebserverPrimaryGroup() { 701 if ( !function_exists( 'posix_getegid' ) || !function_exists( 'posix_getpwuid' ) ) { 702 # I don't know this, this isn't UNIX. 703 return null; 704 } 705 706 # posix_getegid() *not* getmygid() because we want the group of the webserver, 707 # not whoever owns the current script. 708 $gid = posix_getegid(); 709 return posix_getpwuid( $gid )['name'] ?? null; 710 } 711 712 /** 713 * Convert wikitext $text to HTML. 714 * 715 * This is potentially error prone since many parser features require a complete 716 * installed MW database. The solution is to just not use those features when you 717 * write your messages. This appears to work well enough. Basic formatting and 718 * external links work just fine. 719 * 720 * But in case a translator decides to throw in a "#ifexist" or internal link or 721 * whatever, this function is guarded to catch the attempted DB access and to present 722 * some fallback text. 723 * 724 * @param string $text 725 * @param bool $lineStart 726 * @return string 727 */ 728 public function parse( $text, $lineStart = false ) { 729 $parser = MediaWikiServices::getInstance()->getParser(); 730 731 try { 732 $out = $parser->parse( $text, $this->parserTitle, $this->parserOptions, $lineStart ); 733 $html = $out->getText( [ 734 'enableSectionEditLinks' => false, 735 'unwrap' => true, 736 ] ); 737 $html = Parser::stripOuterParagraph( $html ); 738 } catch ( Wikimedia\Services\ServiceDisabledException $e ) { 739 $html = '<!--DB access attempted during parse--> ' . htmlspecialchars( $text ); 740 } 741 742 return $html; 743 } 744 745 /** 746 * @return ParserOptions 747 */ 748 public function getParserOptions() { 749 return $this->parserOptions; 750 } 751 752 public function disableLinkPopups() { 753 $this->parserOptions->setExternalLinkTarget( false ); 754 } 755 756 public function restoreLinkPopups() { 757 global $wgExternalLinkTarget; 758 $this->parserOptions->setExternalLinkTarget( $wgExternalLinkTarget ); 759 } 760 761 /** 762 * Install step which adds a row to the site_stats table with appropriate 763 * initial values. 764 * 765 * @param DatabaseInstaller $installer 766 * 767 * @return Status 768 */ 769 public function populateSiteStats( DatabaseInstaller $installer ) { 770 $status = $installer->getConnection(); 771 if ( !$status->isOK() ) { 772 return $status; 773 } 774 // @phan-suppress-next-line PhanUndeclaredMethod 775 $status->value->insert( 776 'site_stats', 777 [ 778 'ss_row_id' => 1, 779 'ss_total_edits' => 0, 780 'ss_good_articles' => 0, 781 'ss_total_pages' => 0, 782 'ss_users' => 0, 783 'ss_active_users' => 0, 784 'ss_images' => 0 785 ], 786 __METHOD__, 787 'IGNORE' 788 ); 789 790 return Status::newGood(); 791 } 792 793 /** 794 * Environment check for DB types. 795 * @return bool 796 */ 797 protected function envCheckDB() { 798 global $wgLang; 799 /** @var string|null $dbType The user-specified database type */ 800 $dbType = $this->getVar( 'wgDBtype' ); 801 802 $allNames = []; 803 804 // Messages: config-type-mysql, config-type-postgres, config-type-sqlite 805 foreach ( self::getDBTypes() as $name ) { 806 $allNames[] = wfMessage( "config-type-$name" )->text(); 807 } 808 809 $databases = $this->getCompiledDBs(); 810 811 $databases = array_flip( $databases ); 812 $ok = true; 813 foreach ( array_keys( $databases ) as $db ) { 814 $installer = $this->getDBInstaller( $db ); 815 $status = $installer->checkPrerequisites(); 816 if ( !$status->isGood() ) { 817 if ( !$this instanceof WebInstaller && $db === $dbType ) { 818 // Strictly check the key database type instead of just outputting message 819 // Note: No perform this check run from the web installer, since this method always called by 820 // the welcome page under web installation, so $dbType will always be 'mysql' 821 $ok = false; 822 } 823 $this->showStatusMessage( $status ); 824 unset( $databases[$db] ); 825 } 826 } 827 $databases = array_flip( $databases ); 828 if ( !$databases ) { 829 $this->showError( 'config-no-db', $wgLang->commaList( $allNames ), count( $allNames ) ); 830 return false; 831 } 832 return $ok; 833 } 834 835 /** 836 * Environment check for the PCRE module. 837 * 838 * @note If this check were to fail, the parser would 839 * probably throw an exception before the result 840 * of this check is shown to the user. 841 * @return bool 842 */ 843 protected function envCheckPCRE() { 844 Wikimedia\suppressWarnings(); 845 $regexd = preg_replace( '/[\x{0430}-\x{04FF}]/iu', '', '-АБВГД-' ); 846 // Need to check for \p support too, as PCRE can be compiled 847 // with utf8 support, but not unicode property support. 848 // check that \p{Zs} (space separators) matches 849 // U+3000 (Ideographic space) 850 $regexprop = preg_replace( '/\p{Zs}/u', '', "-\u{3000}-" ); 851 Wikimedia\restoreWarnings(); 852 if ( $regexd != '--' || $regexprop != '--' ) { 853 $this->showError( 'config-pcre-no-utf8' ); 854 855 return false; 856 } 857 858 return true; 859 } 860 861 /** 862 * Environment check for available memory. 863 * @return bool 864 */ 865 protected function envCheckMemory() { 866 $limit = ini_get( 'memory_limit' ); 867 868 if ( !$limit || $limit == -1 ) { 869 return true; 870 } 871 872 $n = wfShorthandToInteger( $limit ); 873 874 if ( $n < $this->minMemorySize * 1024 * 1024 ) { 875 $newLimit = "{$this->minMemorySize}M"; 876 877 if ( ini_set( "memory_limit", $newLimit ) === false ) { 878 $this->showMessage( 'config-memory-bad', $limit ); 879 } else { 880 $this->showMessage( 'config-memory-raised', $limit, $newLimit ); 881 $this->setVar( '_RaiseMemory', true ); 882 } 883 } 884 885 return true; 886 } 887 888 /** 889 * Environment check for compiled object cache types. 890 */ 891 protected function envCheckCache() { 892 $caches = []; 893 foreach ( $this->objectCaches as $name => $function ) { 894 if ( function_exists( $function ) ) { 895 $caches[$name] = true; 896 } 897 } 898 899 if ( !$caches ) { 900 $this->showMessage( 'config-no-cache-apcu' ); 901 } 902 903 $this->setVar( '_Caches', $caches ); 904 } 905 906 /** 907 * Scare user to death if they have mod_security or mod_security2 908 * @return bool 909 */ 910 protected function envCheckModSecurity() { 911 if ( self::apacheModulePresent( 'mod_security' ) 912 || self::apacheModulePresent( 'mod_security2' ) ) { 913 $this->showMessage( 'config-mod-security' ); 914 } 915 916 return true; 917 } 918 919 /** 920 * Search for GNU diff3. 921 * @return bool 922 */ 923 protected function envCheckDiff3() { 924 $names = [ "gdiff3", "diff3" ]; 925 if ( wfIsWindows() ) { 926 $names[] = 'diff3.exe'; 927 } 928 $versionInfo = [ '--version', 'GNU diffutils' ]; 929 930 $diff3 = ExecutableFinder::findInDefaultPaths( $names, $versionInfo ); 931 932 if ( $diff3 ) { 933 $this->setVar( 'wgDiff3', $diff3 ); 934 } else { 935 $this->setVar( 'wgDiff3', false ); 936 $this->showMessage( 'config-diff3-bad' ); 937 } 938 939 return true; 940 } 941 942 /** 943 * Environment check for ImageMagick and GD. 944 * @return bool 945 */ 946 protected function envCheckGraphics() { 947 $names = wfIsWindows() ? 'convert.exe' : 'convert'; 948 $versionInfo = [ '-version', 'ImageMagick' ]; 949 $convert = ExecutableFinder::findInDefaultPaths( $names, $versionInfo ); 950 951 $this->setVar( 'wgImageMagickConvertCommand', '' ); 952 if ( $convert ) { 953 $this->setVar( 'wgImageMagickConvertCommand', $convert ); 954 $this->showMessage( 'config-imagemagick', $convert ); 955 } elseif ( function_exists( 'imagejpeg' ) ) { 956 $this->showMessage( 'config-gd' ); 957 } else { 958 $this->showMessage( 'config-no-scaling' ); 959 } 960 961 return true; 962 } 963 964 /** 965 * Search for git. 966 * 967 * @since 1.22 968 * @return bool 969 */ 970 protected function envCheckGit() { 971 $names = wfIsWindows() ? 'git.exe' : 'git'; 972 $versionInfo = [ '--version', 'git version' ]; 973 974 $git = ExecutableFinder::findInDefaultPaths( $names, $versionInfo ); 975 976 if ( $git ) { 977 $this->setVar( 'wgGitBin', $git ); 978 $this->showMessage( 'config-git', $git ); 979 } else { 980 $this->setVar( 'wgGitBin', false ); 981 $this->showMessage( 'config-git-bad' ); 982 } 983 984 return true; 985 } 986 987 /** 988 * Environment check to inform user which server we've assumed. 989 * 990 * @return bool 991 */ 992 protected function envCheckServer() { 993 $server = $this->envGetDefaultServer(); 994 if ( $server !== null ) { 995 $this->showMessage( 'config-using-server', $server ); 996 } 997 return true; 998 } 999 1000 /** 1001 * Environment check to inform user which paths we've assumed. 1002 * 1003 * @return bool 1004 */ 1005 protected function envCheckPath() { 1006 $this->showMessage( 1007 'config-using-uri', 1008 $this->getVar( 'wgServer' ), 1009 $this->getVar( 'wgScriptPath' ) 1010 ); 1011 return true; 1012 } 1013 1014 /** 1015 * Environment check for preferred locale in shell 1016 * @return bool 1017 */ 1018 protected function envCheckShellLocale() { 1019 $os = php_uname( 's' ); 1020 $supported = [ 'Linux', 'SunOS', 'HP-UX', 'Darwin' ]; # Tested these 1021 1022 if ( !in_array( $os, $supported ) ) { 1023 return true; 1024 } 1025 1026 if ( Shell::isDisabled() ) { 1027 return true; 1028 } 1029 1030 # Get a list of available locales. 1031 $result = Shell::command( '/usr/bin/locale', '-a' )->execute(); 1032 1033 if ( $result->getExitCode() != 0 ) { 1034 return true; 1035 } 1036 1037 $lines = $result->getStdout(); 1038 $lines = array_map( 'trim', explode( "\n", $lines ) ); 1039 $candidatesByLocale = []; 1040 $candidatesByLang = []; 1041 foreach ( $lines as $line ) { 1042 if ( $line === '' ) { 1043 continue; 1044 } 1045 1046 if ( !preg_match( '/^([a-zA-Z]+)(_[a-zA-Z]+|)\.(utf8|UTF-8)(@[a-zA-Z_]*|)$/i', $line, $m ) ) { 1047 continue; 1048 } 1049 1050 list( , $lang, , , ) = $m; 1051 1052 $candidatesByLocale[$m[0]] = $m; 1053 $candidatesByLang[$lang][] = $m; 1054 } 1055 1056 # Try the current value of LANG. 1057 if ( isset( $candidatesByLocale[getenv( 'LANG' )] ) ) { 1058 $this->setVar( 'wgShellLocale', getenv( 'LANG' ) ); 1059 1060 return true; 1061 } 1062 1063 # Try the most common ones. 1064 $commonLocales = [ 'C.UTF-8', 'en_US.UTF-8', 'en_US.utf8', 'de_DE.UTF-8', 'de_DE.utf8' ]; 1065 foreach ( $commonLocales as $commonLocale ) { 1066 if ( isset( $candidatesByLocale[$commonLocale] ) ) { 1067 $this->setVar( 'wgShellLocale', $commonLocale ); 1068 1069 return true; 1070 } 1071 } 1072 1073 # Is there an available locale in the Wiki's language? 1074 $wikiLang = $this->getVar( 'wgLanguageCode' ); 1075 1076 if ( isset( $candidatesByLang[$wikiLang] ) ) { 1077 $m = reset( $candidatesByLang[$wikiLang] ); 1078 $this->setVar( 'wgShellLocale', $m[0] ); 1079 1080 return true; 1081 } 1082 1083 # Are there any at all? 1084 if ( count( $candidatesByLocale ) ) { 1085 $m = reset( $candidatesByLocale ); 1086 $this->setVar( 'wgShellLocale', $m[0] ); 1087 1088 return true; 1089 } 1090 1091 # Give up. 1092 return true; 1093 } 1094 1095 /** 1096 * Environment check for the permissions of the uploads directory 1097 * @return bool 1098 */ 1099 protected function envCheckUploadsDirectory() { 1100 global $IP; 1101 1102 $dir = $IP . '/images/'; 1103 $url = $this->getVar( 'wgServer' ) . $this->getVar( 'wgScriptPath' ) . '/images/'; 1104 $safe = !$this->dirIsExecutable( $dir, $url ); 1105 1106 if ( !$safe ) { 1107 $this->showMessage( 'config-uploads-not-safe', $dir ); 1108 } 1109 1110 return true; 1111 } 1112 1113 /** 1114 * Checks if suhosin.get.max_value_length is set, and if so generate 1115 * a warning because it is incompatible with ResourceLoader. 1116 * @return bool 1117 */ 1118 protected function envCheckSuhosinMaxValueLength() { 1119 $currentValue = ini_get( 'suhosin.get.max_value_length' ); 1120 $minRequired = 2000; 1121 $recommended = 5000; 1122 if ( $currentValue > 0 && $currentValue < $minRequired ) { 1123 $this->showError( 'config-suhosin-max-value-length', $currentValue, $minRequired, $recommended ); 1124 return false; 1125 } 1126 1127 return true; 1128 } 1129 1130 /** 1131 * Checks if we're running on 64 bit or not. 32 bit is becoming increasingly 1132 * hard to support, so let's at least warn people. 1133 * 1134 * @return bool 1135 */ 1136 protected function envCheck64Bit() { 1137 if ( PHP_INT_SIZE == 4 ) { 1138 $this->showMessage( 'config-using-32bit' ); 1139 } 1140 1141 return true; 1142 } 1143 1144 /** 1145 * Check the libicu version 1146 */ 1147 protected function envCheckLibicu() { 1148 /** 1149 * This needs to be updated something that the latest libicu 1150 * will properly normalize. This normalization was found at 1151 * https://www.unicode.org/versions/Unicode5.2.0/#Character_Additions 1152 * Note that we use the hex representation to create the code 1153 * points in order to avoid any Unicode-destroying during transit. 1154 */ 1155 $not_normal_c = "\u{FA6C}"; 1156 $normal_c = "\u{242EE}"; 1157 1158 $intl = normalizer_normalize( $not_normal_c, Normalizer::FORM_C ); 1159 1160 $this->showMessage( 'config-unicode-using-intl' ); 1161 if ( $intl !== $normal_c ) { 1162 $this->showMessage( 'config-unicode-update-warning' ); 1163 } 1164 } 1165 1166 /** 1167 * Environment prep for the server hostname. 1168 */ 1169 protected function envPrepServer() { 1170 $server = $this->envGetDefaultServer(); 1171 if ( $server !== null ) { 1172 $this->setVar( 'wgServer', $server ); 1173 } 1174 } 1175 1176 /** 1177 * Helper function to be called from envPrepServer() 1178 * @return string 1179 */ 1180 abstract protected function envGetDefaultServer(); 1181 1182 /** 1183 * Environment prep for setting $IP and $wgScriptPath. 1184 */ 1185 protected function envPrepPath() { 1186 global $IP; 1187 $IP = dirname( dirname( __DIR__ ) ); 1188 $this->setVar( 'IP', $IP ); 1189 } 1190 1191 /** 1192 * Checks if scripts located in the given directory can be executed via the given URL. 1193 * 1194 * Used only by environment checks. 1195 * @param string $dir 1196 * @param string $url 1197 * @return bool|int|string 1198 */ 1199 public function dirIsExecutable( $dir, $url ) { 1200 $scriptTypes = [ 1201 'php' => [ 1202 "<?php echo 'exec';", 1203 "#!/var/env php\n<?php echo 'exec';", 1204 ], 1205 ]; 1206 1207 // it would be good to check other popular languages here, but it'll be slow. 1208 // TODO no need to have a loop if there is going to only be one script type 1209 1210 $httpRequestFactory = MediaWikiServices::getInstance()->getHttpRequestFactory(); 1211 1212 Wikimedia\suppressWarnings(); 1213 1214 foreach ( $scriptTypes as $ext => $contents ) { 1215 foreach ( $contents as $source ) { 1216 $file = 'exectest.' . $ext; 1217 1218 if ( !file_put_contents( $dir . $file, $source ) ) { 1219 break; 1220 } 1221 1222 try { 1223 $text = $httpRequestFactory->get( 1224 $url . $file, 1225 [ 'timeout' => 3 ], 1226 __METHOD__ 1227 ); 1228 } catch ( Exception $e ) { 1229 // HttpRequestFactory::get can throw with allow_url_fopen = false and no curl 1230 // extension. 1231 $text = null; 1232 } 1233 unlink( $dir . $file ); 1234 1235 if ( $text == 'exec' ) { 1236 Wikimedia\restoreWarnings(); 1237 1238 return $ext; 1239 } 1240 } 1241 } 1242 1243 Wikimedia\restoreWarnings(); 1244 1245 return false; 1246 } 1247 1248 /** 1249 * Checks for presence of an Apache module. Works only if PHP is running as an Apache module, too. 1250 * 1251 * @param string $moduleName Name of module to check. 1252 * @return bool 1253 */ 1254 public static function apacheModulePresent( $moduleName ) { 1255 if ( function_exists( 'apache_get_modules' ) && in_array( $moduleName, apache_get_modules() ) ) { 1256 return true; 1257 } 1258 // try it the hard way 1259 ob_start(); 1260 phpinfo( INFO_MODULES ); 1261 $info = ob_get_clean(); 1262 1263 return strpos( $info, $moduleName ) !== false; 1264 } 1265 1266 /** 1267 * ParserOptions are constructed before we determined the language, so fix it 1268 * 1269 * @param Language $lang 1270 */ 1271 public function setParserLanguage( $lang ) { 1272 $this->parserOptions->setTargetLanguage( $lang ); 1273 $this->parserOptions->setUserLang( $lang ); 1274 } 1275 1276 /** 1277 * Overridden by WebInstaller to provide lastPage parameters. 1278 * @param string $page 1279 * @return string 1280 */ 1281 protected function getDocUrl( $page ) { 1282 return "{$_SERVER['PHP_SELF']}?page=" . urlencode( $page ); 1283 } 1284 1285 /** 1286 * Find extensions or skins in a subdirectory of $IP. 1287 * Returns an array containing the value for 'Name' for each found extension. 1288 * 1289 * @param string $directory Directory to search in, relative to $IP, must be either "extensions" 1290 * or "skins" 1291 * @return Status An object containing an error list. If there were no errors, an associative 1292 * array of information about the extension can be found in $status->value. 1293 */ 1294 public function findExtensions( $directory = 'extensions' ) { 1295 switch ( $directory ) { 1296 case 'extensions': 1297 return $this->findExtensionsByType( 'extension', 'extensions' ); 1298 case 'skins': 1299 return $this->findExtensionsByType( 'skin', 'skins' ); 1300 default: 1301 throw new InvalidArgumentException( "Invalid extension type" ); 1302 } 1303 } 1304 1305 /** 1306 * Find extensions or skins, and return an array containing the value for 'Name' for each found 1307 * extension. 1308 * 1309 * @param string $type Either "extension" or "skin" 1310 * @param string $directory Directory to search in, relative to $IP 1311 * @return Status An object containing an error list. If there were no errors, an associative 1312 * array of information about the extension can be found in $status->value. 1313 */ 1314 protected function findExtensionsByType( $type = 'extension', $directory = 'extensions' ) { 1315 if ( $this->getVar( 'IP' ) === null ) { 1316 return Status::newGood( [] ); 1317 } 1318 1319 $extDir = $this->getVar( 'IP' ) . '/' . $directory; 1320 if ( !is_readable( $extDir ) || !is_dir( $extDir ) ) { 1321 return Status::newGood( [] ); 1322 } 1323 1324 // @phan-suppress-next-line SecurityCheck-PathTraversal False positive 1325 $dh = opendir( $extDir ); 1326 $exts = []; 1327 $status = new Status; 1328 while ( ( $file = readdir( $dh ) ) !== false ) { 1329 // skip non-dirs and hidden directories 1330 if ( !is_dir( "$extDir/$file" ) || $file[0] === '.' ) { 1331 continue; 1332 } 1333 $extStatus = $this->getExtensionInfo( $type, $directory, $file ); 1334 if ( $extStatus->isOK() ) { 1335 $exts[$file] = $extStatus->value; 1336 } elseif ( $extStatus->hasMessage( 'config-extension-not-found' ) ) { 1337 // (T225512) The directory is not actually an extension. Downgrade to warning. 1338 $status->warning( 'config-extension-not-found', $file ); 1339 } else { 1340 $status->merge( $extStatus ); 1341 } 1342 } 1343 closedir( $dh ); 1344 uksort( $exts, 'strnatcasecmp' ); 1345 1346 $status->value = $exts; 1347 1348 return $status; 1349 } 1350 1351 /** 1352 * @param string $type Either "extension" or "skin" 1353 * @param string $parentRelPath The parent directory relative to $IP 1354 * @param string $name The extension or skin name 1355 * @return Status An object containing an error list. If there were no errors, an associative 1356 * array of information about the extension can be found in $status->value. 1357 */ 1358 protected function getExtensionInfo( $type, $parentRelPath, $name ) { 1359 if ( $this->getVar( 'IP' ) === null ) { 1360 throw new Exception( 'Cannot find extensions since the IP variable is not yet set' ); 1361 } 1362 if ( $type !== 'extension' && $type !== 'skin' ) { 1363 throw new InvalidArgumentException( "Invalid extension type" ); 1364 } 1365 $absDir = $this->getVar( 'IP' ) . "/$parentRelPath/$name"; 1366 $relDir = "../$parentRelPath/$name"; 1367 if ( !is_dir( $absDir ) ) { 1368 return Status::newFatal( 'config-extension-not-found', $name ); 1369 } 1370 $jsonFile = $type . '.json'; 1371 $fullJsonFile = "$absDir/$jsonFile"; 1372 $isJson = file_exists( $fullJsonFile ); 1373 $isPhp = false; 1374 if ( !$isJson ) { 1375 // Only fallback to PHP file if JSON doesn't exist 1376 $fullPhpFile = "$absDir/$name.php"; 1377 $isPhp = file_exists( $fullPhpFile ); 1378 } 1379 if ( !$isJson && !$isPhp ) { 1380 return Status::newFatal( 'config-extension-not-found', $name ); 1381 } 1382 1383 // Extension exists. Now see if there are screenshots 1384 $info = []; 1385 if ( is_dir( "$absDir/screenshots" ) ) { 1386 $paths = glob( "$absDir/screenshots/*.png" ); 1387 foreach ( $paths as $path ) { 1388 $info['screenshots'][] = str_replace( $absDir, $relDir, $path ); 1389 } 1390 } 1391 1392 if ( $isJson ) { 1393 $jsonStatus = $this->readExtension( $fullJsonFile ); 1394 if ( !$jsonStatus->isOK() ) { 1395 return $jsonStatus; 1396 } 1397 $info += $jsonStatus->value; 1398 } 1399 1400 // @phan-suppress-next-line SecurityCheckMulti 1401 return Status::newGood( $info ); 1402 } 1403 1404 /** 1405 * @param string $fullJsonFile 1406 * @param array $extDeps 1407 * @param array $skinDeps 1408 * 1409 * @return Status On success, an array of extension information is in $status->value. On 1410 * failure, the Status object will have an error list. 1411 */ 1412 private function readExtension( $fullJsonFile, $extDeps = [], $skinDeps = [] ) { 1413 $load = [ 1414 $fullJsonFile => 1 1415 ]; 1416 if ( $extDeps ) { 1417 $extDir = $this->getVar( 'IP' ) . '/extensions'; 1418 foreach ( $extDeps as $dep ) { 1419 $fname = "$extDir/$dep/extension.json"; 1420 if ( !file_exists( $fname ) ) { 1421 return Status::newFatal( 'config-extension-not-found', $dep ); 1422 } 1423 $load[$fname] = 1; 1424 } 1425 } 1426 if ( $skinDeps ) { 1427 $skinDir = $this->getVar( 'IP' ) . '/skins'; 1428 foreach ( $skinDeps as $dep ) { 1429 $fname = "$skinDir/$dep/skin.json"; 1430 if ( !file_exists( $fname ) ) { 1431 return Status::newFatal( 'config-extension-not-found', $dep ); 1432 } 1433 $load[$fname] = 1; 1434 } 1435 } 1436 $registry = new ExtensionRegistry(); 1437 try { 1438 $info = $registry->readFromQueue( $load ); 1439 } catch ( ExtensionDependencyError $e ) { 1440 if ( $e->incompatibleCore || $e->incompatibleSkins 1441 || $e->incompatibleExtensions 1442 ) { 1443 // If something is incompatible with a dependency, we have no real 1444 // option besides skipping it 1445 return Status::newFatal( 'config-extension-dependency', 1446 basename( dirname( $fullJsonFile ) ), $e->getMessage() ); 1447 } elseif ( $e->missingExtensions || $e->missingSkins ) { 1448 // There's an extension missing in the dependency tree, 1449 // so add those to the dependency list and try again 1450 $status = $this->readExtension( 1451 $fullJsonFile, 1452 array_merge( $extDeps, $e->missingExtensions ), 1453 array_merge( $skinDeps, $e->missingSkins ) 1454 ); 1455 if ( !$status->isOK() && !$status->hasMessage( 'config-extension-dependency' ) ) { 1456 $status = Status::newFatal( 'config-extension-dependency', 1457 basename( dirname( $fullJsonFile ) ), $status->getMessage() ); 1458 } 1459 return $status; 1460 } 1461 // Some other kind of dependency error? 1462 return Status::newFatal( 'config-extension-dependency', 1463 basename( dirname( $fullJsonFile ) ), $e->getMessage() ); 1464 } 1465 $ret = []; 1466 // The order of credits will be the order of $load, 1467 // so the first extension is the one we want to load, 1468 // everything else is a dependency 1469 $i = 0; 1470 foreach ( $info['credits'] as $name => $credit ) { 1471 $i++; 1472 if ( $i == 1 ) { 1473 // Extension we want to load 1474 continue; 1475 } 1476 $type = basename( $credit['path'] ) === 'skin.json' ? 'skins' : 'extensions'; 1477 $ret['requires'][$type][] = $credit['name']; 1478 } 1479 $credits = array_values( $info['credits'] )[0]; 1480 if ( isset( $credits['url'] ) ) { 1481 $ret['url'] = $credits['url']; 1482 } 1483 $ret['type'] = $credits['type']; 1484 1485 return Status::newGood( $ret ); 1486 } 1487 1488 /** 1489 * Returns a default value to be used for $wgDefaultSkin: normally the one set in DefaultSettings, 1490 * but will fall back to another if the default skin is missing and some other one is present 1491 * instead. 1492 * 1493 * @param string[] $skinNames Names of installed skins. 1494 * @return string 1495 */ 1496 public function getDefaultSkin( array $skinNames ) { 1497 $defaultSkin = $GLOBALS['wgDefaultSkin']; 1498 if ( !$skinNames || in_array( $defaultSkin, $skinNames ) ) { 1499 return $defaultSkin; 1500 } else { 1501 return $skinNames[0]; 1502 } 1503 } 1504 1505 /** 1506 * Installs the auto-detected extensions. 1507 * 1508 * @return Status 1509 */ 1510 protected function includeExtensions() { 1511 // Marker for DatabaseUpdater::loadExtensions so we don't 1512 // double load extensions 1513 define( 'MW_EXTENSIONS_LOADED', true ); 1514 1515 $legacySchemaHooks = $this->getAutoExtensionLegacyHooks(); 1516 $data = $this->getAutoExtensionData(); 1517 if ( isset( $data['globals']['wgHooks']['LoadExtensionSchemaUpdates'] ) ) { 1518 $legacySchemaHooks = array_merge( $legacySchemaHooks, 1519 $data['globals']['wgHooks']['LoadExtensionSchemaUpdates'] ); 1520 } 1521 $extDeprecatedHooks = $data['attributes']['DeprecatedHooks'] ?? []; 1522 1523 $this->autoExtensionHookContainer = new HookContainer( 1524 new StaticHookRegistry( 1525 [ 'LoadExtensionSchemaUpdates' => $legacySchemaHooks ], 1526 $data['attributes']['Hooks'] ?? [], 1527 $extDeprecatedHooks 1528 ), 1529 MediaWikiServices::getInstance()->getObjectFactory() 1530 ); 1531 1532 return Status::newGood(); 1533 } 1534 1535 /** 1536 * Auto-detect extensions with an old style .php registration file, load 1537 * the extensions, and return the merged $wgHooks array. 1538 * 1539 * @suppress SecurityCheck-PathTraversal It thinks $exts/$IP is user controlled but they are not. 1540 * @return array 1541 */ 1542 protected function getAutoExtensionLegacyHooks() { 1543 $exts = $this->getVar( '_Extensions' ); 1544 $installPath = $this->getVar( 'IP' ); 1545 $files = []; 1546 foreach ( $exts as $e ) { 1547 if ( file_exists( "$installPath/extensions/$e/$e.php" ) ) { 1548 $files[] = "$installPath/extensions/$e/$e.php"; 1549 } 1550 } 1551 1552 if ( $files ) { 1553 return $this->includeExtensionFiles( $files ); 1554 } else { 1555 return []; 1556 } 1557 } 1558 1559 /** 1560 * Include the specified extension PHP files. Populate $wgAutoloadClasses 1561 * and return the LoadExtensionSchemaUpdates hooks. 1562 * 1563 * @param string[] $files 1564 * @return array LoadExtensionSchemaUpdates legacy hooks 1565 */ 1566 protected function includeExtensionFiles( $files ) { 1567 global $IP; 1568 $IP = $this->getVar( 'IP' ); 1569 1570 /** 1571 * We need to include DefaultSettings before including extensions to avoid 1572 * warnings about unset variables. However, the only thing we really 1573 * want here is $wgHooks['LoadExtensionSchemaUpdates']. This won't work 1574 * if the extension has hidden hook registration in $wgExtensionFunctions, 1575 * but we're not opening that can of worms 1576 * @see https://phabricator.wikimedia.org/T28857 1577 */ 1578 // @phan-suppress-next-line SecurityCheck-PathTraversal 1579 require "$IP/includes/DefaultSettings.php"; 1580 1581 // phpcs:ignore MediaWiki.VariableAnalysis.UnusedGlobalVariables 1582 global $wgAutoloadClasses; 1583 foreach ( $files as $file ) { 1584 require_once $file; 1585 } 1586 1587 // @phpcs:disable MediaWiki.VariableAnalysis.MisleadingGlobalNames.Misleading$wgHooks 1588 // @phan-suppress-next-line PhanUndeclaredVariable,PhanCoalescingAlwaysNull $wgHooks is set by DefaultSettings 1589 $hooksWeWant = $wgHooks['LoadExtensionSchemaUpdates'] ?? []; 1590 // @phpcs:enable MediaWiki.VariableAnalysis.MisleadingGlobalNames.Misleading$wgHooks 1591 1592 // Ignore everyone else's hooks. Lord knows what someone might be doing 1593 // in ParserFirstCallInit (see T29171) 1594 return [ 'LoadExtensionSchemaUpdates' => $hooksWeWant ]; 1595 } 1596 1597 /** 1598 * Auto-detect extensions with an extension.json file. Load the extensions, 1599 * populate $wgAutoloadClasses and return the merged registry data. 1600 * 1601 * @return array 1602 */ 1603 protected function getAutoExtensionData() { 1604 $exts = $this->getVar( '_Extensions' ); 1605 $installPath = $this->getVar( 'IP' ); 1606 $queue = []; 1607 foreach ( $exts as $e ) { 1608 if ( file_exists( "$installPath/extensions/$e/extension.json" ) ) { 1609 $queue["$installPath/extensions/$e/extension.json"] = 1; 1610 } 1611 } 1612 1613 $registry = new ExtensionRegistry(); 1614 $data = $registry->readFromQueue( $queue ); 1615 global $wgAutoloadClasses; 1616 $wgAutoloadClasses += $data['globals']['wgAutoloadClasses']; 1617 return $data; 1618 } 1619 1620 /** 1621 * Get the hook container previously populated by includeExtensions(). 1622 * 1623 * @internal For use by DatabaseInstaller 1624 * @since 1.36 1625 * @return HookContainer 1626 */ 1627 public function getAutoExtensionHookContainer() { 1628 if ( !$this->autoExtensionHookContainer ) { 1629 throw new \Exception( __METHOD__ . 1630 ': includeExtensions() has not been called' ); 1631 } 1632 return $this->autoExtensionHookContainer; 1633 } 1634 1635 /** 1636 * Get an array of install steps. Should always be in the format of 1637 * [ 1638 * 'name' => 'someuniquename', 1639 * 'callback' => [ $obj, 'method' ], 1640 * ] 1641 * There must be a config-install-$name message defined per step, which will 1642 * be shown on install. 1643 * 1644 * @param DatabaseInstaller $installer DatabaseInstaller so we can make callbacks 1645 * @return array[] 1646 * @phan-return array<int,array{name:string,callback:array{0:object,1:string}}> 1647 */ 1648 protected function getInstallSteps( DatabaseInstaller $installer ) { 1649 $coreInstallSteps = [ 1650 [ 'name' => 'database', 'callback' => [ $installer, 'setupDatabase' ] ], 1651 [ 'name' => 'tables', 'callback' => [ $installer, 'createTables' ] ], 1652 [ 'name' => 'tables-manual', 'callback' => [ $installer, 'createManualTables' ] ], 1653 [ 'name' => 'interwiki', 'callback' => [ $installer, 'populateInterwikiTable' ] ], 1654 [ 'name' => 'stats', 'callback' => [ $this, 'populateSiteStats' ] ], 1655 [ 'name' => 'keys', 'callback' => [ $this, 'generateKeys' ] ], 1656 [ 'name' => 'updates', 'callback' => [ $installer, 'insertUpdateKeys' ] ], 1657 [ 'name' => 'restore-services', 'callback' => [ $this, 'restoreServices' ] ], 1658 [ 'name' => 'sysop', 'callback' => [ $this, 'createSysop' ] ], 1659 [ 'name' => 'mainpage', 'callback' => [ $this, 'createMainpage' ] ], 1660 ]; 1661 1662 // Build the array of install steps starting from the core install list, 1663 // then adding any callbacks that wanted to attach after a given step 1664 foreach ( $coreInstallSteps as $step ) { 1665 $this->installSteps[] = $step; 1666 if ( isset( $this->extraInstallSteps[$step['name']] ) ) { 1667 $this->installSteps = array_merge( 1668 $this->installSteps, 1669 $this->extraInstallSteps[$step['name']] 1670 ); 1671 } 1672 } 1673 1674 // Prepend any steps that want to be at the beginning 1675 if ( isset( $this->extraInstallSteps['BEGINNING'] ) ) { 1676 $this->installSteps = array_merge( 1677 $this->extraInstallSteps['BEGINNING'], 1678 $this->installSteps 1679 ); 1680 } 1681 1682 // Extensions should always go first, chance to tie into hooks and such 1683 if ( count( $this->getVar( '_Extensions' ) ) ) { 1684 array_unshift( $this->installSteps, 1685 [ 'name' => 'extensions', 'callback' => [ $this, 'includeExtensions' ] ] 1686 ); 1687 $this->installSteps[] = [ 1688 'name' => 'extension-tables', 1689 'callback' => [ $installer, 'createExtensionTables' ] 1690 ]; 1691 } 1692 1693 return $this->installSteps; 1694 } 1695 1696 /** 1697 * Actually perform the installation. 1698 * 1699 * @param callable $startCB A callback array for the beginning of each step 1700 * @param callable $endCB A callback array for the end of each step 1701 * 1702 * @return Status[] 1703 */ 1704 public function performInstallation( $startCB, $endCB ) { 1705 $installResults = []; 1706 $installer = $this->getDBInstaller(); 1707 $installer->preInstall(); 1708 $steps = $this->getInstallSteps( $installer ); 1709 foreach ( $steps as $stepObj ) { 1710 $name = $stepObj['name']; 1711 call_user_func_array( $startCB, [ $name ] ); 1712 1713 // Perform the callback step 1714 $status = call_user_func( $stepObj['callback'], $installer ); 1715 1716 // Output and save the results 1717 call_user_func( $endCB, $name, $status ); 1718 $installResults[$name] = $status; 1719 1720 // If we've hit some sort of fatal, we need to bail. 1721 // Callback already had a chance to do output above. 1722 if ( !$status->isOK() ) { 1723 break; 1724 } 1725 } 1726 if ( $status->isOK() ) { 1727 $this->showMessage( 1728 'config-install-db-success' 1729 ); 1730 $this->setVar( '_InstallDone', true ); 1731 } 1732 1733 return $installResults; 1734 } 1735 1736 /** 1737 * Generate $wgSecretKey. Will warn if we had to use an insecure random source. 1738 * 1739 * @return Status 1740 */ 1741 public function generateKeys() { 1742 $keys = [ 'wgSecretKey' => 64 ]; 1743 if ( strval( $this->getVar( 'wgUpgradeKey' ) ) === '' ) { 1744 $keys['wgUpgradeKey'] = 16; 1745 } 1746 1747 return $this->doGenerateKeys( $keys ); 1748 } 1749 1750 /** 1751 * Restore services that have been redefined in the early stage of installation 1752 * @return Status 1753 */ 1754 public function restoreServices() { 1755 $this->resetMediaWikiServices( null, [ 1756 'UserOptionsLookup' => static function ( MediaWikiServices $services ) { 1757 return $services->get( 'UserOptionsManager' ); 1758 } 1759 ] ); 1760 return Status::newGood(); 1761 } 1762 1763 /** 1764 * Generate a secret value for variables using a secure generator. 1765 * 1766 * @param array $keys 1767 * @return Status 1768 */ 1769 protected function doGenerateKeys( $keys ) { 1770 foreach ( $keys as $name => $length ) { 1771 $secretKey = MWCryptRand::generateHex( $length ); 1772 $this->setVar( $name, $secretKey ); 1773 } 1774 return Status::newGood(); 1775 } 1776 1777 /** 1778 * Create the first user account, grant it sysop, bureaucrat and interface-admin rights 1779 * 1780 * @return Status 1781 */ 1782 protected function createSysop() { 1783 $name = $this->getVar( '_AdminName' ); 1784 $user = User::newFromName( $name ); 1785 1786 if ( !$user ) { 1787 // We should've validated this earlier anyway! 1788 return Status::newFatal( 'config-admin-error-user', $name ); 1789 } 1790 1791 if ( $user->idForName() == 0 ) { 1792 $user->addToDatabase(); 1793 1794 $password = $this->getVar( '_AdminPassword' ); 1795 $status = $user->changeAuthenticationData( [ 1796 'username' => $user->getName(), 1797 'password' => $password, 1798 'retype' => $password, 1799 ] ); 1800 if ( !$status->isGood() ) { 1801 return Status::newFatal( 'config-admin-error-password', 1802 $name, $status->getWikiText( null, null, $this->getVar( '_UserLang' ) ) ); 1803 } 1804 1805 $userGroupManager = MediaWikiServices::getInstance()->getUserGroupManager(); 1806 $userGroupManager->addUserToGroup( $user, 'sysop' ); 1807 $userGroupManager->addUserToGroup( $user, 'bureaucrat' ); 1808 $userGroupManager->addUserToGroup( $user, 'interface-admin' ); 1809 if ( $this->getVar( '_AdminEmail' ) ) { 1810 $user->setEmail( $this->getVar( '_AdminEmail' ) ); 1811 } 1812 $user->saveSettings(); 1813 1814 // Update user count 1815 $ssUpdate = SiteStatsUpdate::factory( [ 'users' => 1 ] ); 1816 $ssUpdate->doUpdate(); 1817 } 1818 1819 if ( $this->getVar( '_Subscribe' ) && $this->getVar( '_AdminEmail' ) ) { 1820 return $this->subscribeToMediaWikiAnnounce(); 1821 } 1822 return Status::newGood(); 1823 } 1824 1825 /** 1826 * @return Status 1827 */ 1828 private function subscribeToMediaWikiAnnounce() { 1829 $status = Status::newGood(); 1830 $http = MediaWikiServices::getInstance()->getHttpRequestFactory(); 1831 if ( !$http->canMakeRequests() ) { 1832 $status->warning( 'config-install-subscribe-fail', 1833 wfMessage( 'config-install-subscribe-notpossible' ) ); 1834 return $status; 1835 } 1836 1837 // Create subscription request 1838 $params = [ 'email' => $this->getVar( '_AdminEmail' ) ]; 1839 $req = $http->create( self::MEDIAWIKI_ANNOUNCE_URL . 'anonymous_subscribe', 1840 [ 'method' => 'POST', 'postData' => $params ], __METHOD__ ); 1841 1842 // Add headers needed to pass Django's CSRF checks 1843 $token = str_repeat( 'a', 64 ); 1844 $req->setHeader( 'Referer', self::MEDIAWIKI_ANNOUNCE_URL ); 1845 $req->setHeader( 'Cookie', "csrftoken=$token" ); 1846 $req->setHeader( 'X-CSRFToken', $token ); 1847 1848 // Send subscription request 1849 $reqStatus = $req->execute(); 1850 if ( !$reqStatus->isOK() ) { 1851 $status->warning( 'config-install-subscribe-fail', 1852 Status::wrap( $reqStatus )->getMessage() ); 1853 return $status; 1854 } 1855 1856 // Was the request submitted successfully? 1857 // The status message is displayed after a redirect, using Django's messages 1858 // framework, so load the list summary page and look for the expected text. 1859 // (Though parsing the cookie set by the framework may be possible, it isn't 1860 // simple, since the format of the cookie has changed between versions.) 1861 $checkReq = $http->create( self::MEDIAWIKI_ANNOUNCE_URL, [], __METHOD__ ); 1862 $checkReq->setCookieJar( $req->getCookieJar() ); 1863 if ( !$checkReq->execute()->isOK() ) { 1864 $status->warning( 'config-install-subscribe-possiblefail' ); 1865 return $status; 1866 } 1867 $html = $checkReq->getContent(); 1868 if ( strpos( $html, 'Please check your inbox for further instructions' ) !== false ) { 1869 // Success 1870 } elseif ( strpos( $html, 'Member already subscribed' ) !== false ) { 1871 $status->warning( 'config-install-subscribe-alreadysubscribed' ); 1872 } elseif ( strpos( $html, 'Subscription request already pending' ) !== false ) { 1873 $status->warning( 'config-install-subscribe-alreadypending' ); 1874 } else { 1875 $status->warning( 'config-install-subscribe-possiblefail' ); 1876 } 1877 return $status; 1878 } 1879 1880 /** 1881 * Insert Main Page with default content. 1882 * 1883 * @param DatabaseInstaller $installer 1884 * @return Status 1885 */ 1886 protected function createMainpage( DatabaseInstaller $installer ) { 1887 $status = Status::newGood(); 1888 $title = Title::newMainPage(); 1889 if ( $title->exists() ) { 1890 $status->warning( 'config-install-mainpage-exists' ); 1891 return $status; 1892 } 1893 try { 1894 $page = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title ); 1895 $content = new WikitextContent( 1896 wfMessage( 'mainpagetext' )->inContentLanguage()->text() . "\n\n" . 1897 wfMessage( 'mainpagedocfooter' )->inContentLanguage()->text() 1898 ); 1899 1900 $status = $page->doUserEditContent( 1901 $content, 1902 User::newSystemUser( 'MediaWiki default' ), 1903 '', 1904 EDIT_NEW 1905 ); 1906 } catch ( Exception $e ) { 1907 // using raw, because $wgShowExceptionDetails can not be set yet 1908 $status->fatal( 'config-install-mainpage-failed', $e->getMessage() ); 1909 } 1910 1911 return $status; 1912 } 1913 1914 /** 1915 * Override the necessary bits of the config to run an installation. 1916 */ 1917 public static function overrideConfig() { 1918 // Use PHP's built-in session handling, since MediaWiki's 1919 // SessionHandler can't work before we have an object cache set up. 1920 if ( !defined( 'MW_NO_SESSION_HANDLER' ) ) { 1921 define( 'MW_NO_SESSION_HANDLER', 1 ); 1922 } 1923 1924 // Don't access the database 1925 $GLOBALS['wgUseDatabaseMessages'] = false; 1926 // Don't cache langconv tables 1927 $GLOBALS['wgLanguageConverterCacheType'] = CACHE_NONE; 1928 // Don't try to cache ResourceLoader dependencies in the database 1929 $GLOBALS['wgResourceLoaderUseObjectCacheForDeps'] = true; 1930 // Debug-friendly 1931 $GLOBALS['wgShowExceptionDetails'] = true; 1932 $GLOBALS['wgShowHostnames'] = true; 1933 // Don't break forms 1934 $GLOBALS['wgExternalLinkTarget'] = '_blank'; 1935 1936 // Allow multiple ob_flush() calls 1937 $GLOBALS['wgDisableOutputCompression'] = true; 1938 1939 // Use a sensible cookie prefix (not my_wiki) 1940 $GLOBALS['wgCookiePrefix'] = 'mw_installer'; 1941 1942 // Some of the environment checks make shell requests, remove limits 1943 $GLOBALS['wgMaxShellMemory'] = 0; 1944 1945 // Override the default CookieSessionProvider with a dummy 1946 // implementation that won't stomp on PHP's cookies. 1947 $GLOBALS['wgSessionProviders'] = [ 1948 [ 1949 'class' => InstallerSessionProvider::class, 1950 'args' => [ [ 1951 'priority' => 1, 1952 ] ] 1953 ] 1954 ]; 1955 1956 // Don't use the DB as the main stash 1957 $GLOBALS['wgMainStash'] = CACHE_NONE; 1958 1959 // Don't try to use any object cache for SessionManager either. 1960 $GLOBALS['wgSessionCacheType'] = CACHE_NONE; 1961 1962 // Set a dummy $wgServer to bypass the check in Setup.php, the 1963 // web installer will automatically detect it and not use this value. 1964 $GLOBALS['wgServer'] = 'https://.invalid'; 1965 } 1966 1967 /** 1968 * Add an installation step following the given step. 1969 * 1970 * @param array $callback A valid installation callback array, in this form: 1971 * [ 'name' => 'some-unique-name', 'callback' => [ $obj, 'function' ] ]; 1972 * @param string $findStep The step to find. Omit to put the step at the beginning 1973 */ 1974 public function addInstallStep( $callback, $findStep = 'BEGINNING' ) { 1975 $this->extraInstallSteps[$findStep][] = $callback; 1976 } 1977 1978 /** 1979 * Disable the time limit for execution. 1980 * Some long-running pages (Install, Upgrade) will want to do this 1981 */ 1982 protected function disableTimeLimit() { 1983 Wikimedia\suppressWarnings(); 1984 set_time_limit( 0 ); 1985 Wikimedia\restoreWarnings(); 1986 } 1987} 1988