Merge "docs: Fix typo"
[mediawiki.git] / includes / installer / Installer.php
blob41de0a5c227c968a821e603ffc3b5fe2a53b8e1e
1 <?php
2 /**
3 * Base code for MediaWiki installer.
5 * DO NOT PATCH THIS FILE IF YOU NEED TO CHANGE INSTALLER BEHAVIOR IN YOUR PACKAGE!
6 * See mw-config/overrides/README for details.
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.
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.
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
23 * @file
24 * @ingroup Installer
27 namespace MediaWiki\Installer;
29 use Exception;
30 use ExecutableFinder;
31 use GuzzleHttp\Psr7\Header;
32 use IntlChar;
33 use InvalidArgumentException;
34 use MediaWiki\Config\Config;
35 use MediaWiki\Config\GlobalVarConfig;
36 use MediaWiki\Config\HashConfig;
37 use MediaWiki\Config\MultiConfig;
38 use MediaWiki\Context\RequestContext;
39 use MediaWiki\HookContainer\HookContainer;
40 use MediaWiki\Installer\Task\RestoredServicesProvider;
41 use MediaWiki\Installer\Task\TaskFactory;
42 use MediaWiki\Installer\Task\TaskList;
43 use MediaWiki\Installer\Task\TaskRunner;
44 use MediaWiki\Language\Language;
45 use MediaWiki\MainConfigNames;
46 use MediaWiki\MainConfigSchema;
47 use MediaWiki\MediaWikiServices;
48 use MediaWiki\Parser\Parser;
49 use MediaWiki\Parser\ParserOptions;
50 use MediaWiki\Registration\ExtensionDependencyError;
51 use MediaWiki\Registration\ExtensionRegistry;
52 use MediaWiki\Settings\SettingsBuilder;
53 use MediaWiki\Status\Status;
54 use MediaWiki\StubObject\StubGlobalUser;
55 use MediaWiki\Title\Title;
56 use MediaWiki\User\User;
57 use MWCryptRand;
58 use RuntimeException;
59 use Wikimedia\AtEase\AtEase;
60 use Wikimedia\Message\MessageSpecifier;
61 use Wikimedia\ObjectCache\EmptyBagOStuff;
62 use Wikimedia\Services\ServiceDisabledException;
64 /**
65 * The Installer helps admins create or upgrade their wiki.
67 * The installer classes are exposed through these human interfaces:
69 * - The `maintenance/install.php` script, backed by CliInstaller.
70 * - The `maintenance/update.php` script, backed by DatabaseUpdater.
71 * - The `mw-config/index.php` web entry point, backed by WebInstaller.
73 * @defgroup Installer Installer
76 /**
77 * Base installer class.
79 * This class provides the base for installation and update functionality
80 * for both MediaWiki core and extensions.
82 * @ingroup Installer
83 * @since 1.17
85 abstract class Installer {
87 /**
88 * @var array
90 protected $settings;
92 /**
93 * List of detected DBs, access using getCompiledDBs().
95 * @var array
97 protected $compiledDBs;
99 /**
100 * Cached DB installer instances, access using getDBInstaller().
102 * @var array
104 protected $dbInstallers = [];
107 * Minimum memory size in MiB.
109 * @var int
111 protected $minMemorySize = 50;
114 * Cached Title, used by parse().
116 * @var Title
118 protected $parserTitle;
121 * Cached ParserOptions, used by parse().
123 * @var ParserOptions
125 protected $parserOptions;
128 * Known database types. These correspond to the class names <type>Installer,
129 * and are also MediaWiki database types valid for $wgDBtype.
131 * To add a new type, create a <type>Installer class and a Database<type>
132 * class, and add a config-type-<type> message to MessagesEn.php.
134 * @var array
136 protected static $dbTypes = [
137 'mysql',
138 'postgres',
139 'sqlite',
143 * A list of environment check methods called by doEnvironmentChecks().
144 * These may output warnings using showMessage(), and/or abort the
145 * installation process by returning false.
147 * In the WebInstaller, variables set here will be saved to the session and
148 * will be available to later pages in the same session. But if you need
149 * dynamic defaults to be available before the welcome page completes, say
150 * in the initial CSS request, add something to getDefaultSettings().
152 * @var array
154 protected $envChecks = [
155 'envCheckLibicu',
156 'envCheckDB',
157 'envCheckPCRE',
158 'envCheckMemory',
159 'envCheckCache',
160 'envCheckModSecurity',
161 'envCheckDiff3',
162 'envCheckGraphics',
163 'envCheckGit',
164 'envCheckServer',
165 'envCheckPath',
166 'envCheckUploadsDirectory',
167 'envCheckUploadsServerResponse',
168 'envCheck64Bit',
172 * MediaWiki configuration globals that will eventually be passed through
173 * to LocalSettings.php. The names only are given here, the defaults
174 * typically come from config-schema.yaml.
176 private const DEFAULT_VAR_NAMES = [
177 MainConfigNames::Sitename,
178 MainConfigNames::PasswordSender,
179 MainConfigNames::LanguageCode,
180 MainConfigNames::Localtimezone,
181 MainConfigNames::RightsIcon,
182 MainConfigNames::RightsText,
183 MainConfigNames::RightsUrl,
184 MainConfigNames::EnableEmail,
185 MainConfigNames::EnableUserEmail,
186 MainConfigNames::EnotifUserTalk,
187 MainConfigNames::EnotifWatchlist,
188 MainConfigNames::EmailAuthentication,
189 MainConfigNames::DBname,
190 MainConfigNames::DBtype,
191 MainConfigNames::Diff3,
192 MainConfigNames::ImageMagickConvertCommand,
193 MainConfigNames::GitBin,
194 MainConfigNames::ScriptPath,
195 MainConfigNames::MetaNamespace,
196 MainConfigNames::DeletedDirectory,
197 MainConfigNames::EnableUploads,
198 MainConfigNames::SecretKey,
199 MainConfigNames::UseInstantCommons,
200 MainConfigNames::UpgradeKey,
201 MainConfigNames::DefaultSkin,
202 MainConfigNames::Pingback,
203 MainConfigNames::InstallerInitialPages,
207 * Variables that are stored alongside globals, and are used for any
208 * configuration of the installation process aside from the MediaWiki
209 * configuration. Map of names to defaults.
211 * @var array
213 protected $internalDefaults = [
214 '_UserLang' => 'en',
215 '_Environment' => false,
216 '_RaiseMemory' => false,
217 '_UpgradeDone' => false,
218 '_InstallDone' => false,
219 '_Caches' => [],
220 '_InstallPassword' => '',
221 '_SameAccount' => true,
222 '_CreateDBAccount' => false,
223 '_NamespaceType' => 'site-name',
224 '_AdminName' => '', // will be set later, when the user selects language
225 '_AdminPassword' => '',
226 '_AdminPasswordConfirm' => '',
227 '_AdminEmail' => '',
228 '_Subscribe' => false,
229 '_SkipOptional' => 'continue',
230 '_RightsProfile' => 'wiki',
231 '_LicenseCode' => 'none',
232 '_CCDone' => false,
233 '_Extensions' => [],
234 '_Skins' => [],
235 '_MemCachedServers' => '',
236 '_UpgradeKeySupplied' => false,
237 '_ExistingDBSettings' => false,
238 '_LogoWordmark' => '',
239 '_LogoWordmarkWidth' => 119,
240 '_LogoWordmarkHeight' => 18,
241 // Single quotes are intentional, LocalSettingsGenerator must output this unescaped.
242 '_Logo1x' => '$wgResourceBasePath/resources/assets/change-your-logo.svg',
243 '_LogoIcon' => '$wgResourceBasePath/resources/assets/change-your-logo-icon.svg',
244 '_LogoTagline' => '',
245 '_LogoTaglineWidth' => 117,
246 '_LogoTaglineHeight' => 13,
247 '_WithDevelopmentSettings' => false,
248 'wgAuthenticationTokenVersion' => 1,
252 * Extra steps for installation, for things like DatabaseInstallers to modify
254 * @var array
256 protected $extraInstallSteps = [];
259 * Known object cache types and the functions used to test for their existence.
261 * @var array
263 protected $objectCaches = [
264 'apcu' => 'apcu_fetch',
268 * User rights profiles.
270 * @var array
272 public $rightsProfiles = [
273 'wiki' => [],
274 'no-anon' => [
275 '*' => [ 'edit' => false ]
277 'fishbowl' => [
278 '*' => [
279 'createaccount' => false,
280 'edit' => false,
283 'private' => [
284 '*' => [
285 'createaccount' => false,
286 'edit' => false,
287 'read' => false,
293 * License types.
295 * @var array
297 public $licenses = [
298 'cc-by' => [
299 'url' => 'https://creativecommons.org/licenses/by/4.0/',
300 'icon' => '$wgResourceBasePath/resources/assets/licenses/cc-by.png',
302 'cc-by-sa' => [
303 'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
304 'icon' => '$wgResourceBasePath/resources/assets/licenses/cc-by-sa.png',
306 'cc-by-nc-sa' => [
307 'url' => 'https://creativecommons.org/licenses/by-nc-sa/4.0/',
308 'icon' => '$wgResourceBasePath/resources/assets/licenses/cc-by-nc-sa.png',
310 'cc-0' => [
311 'url' => 'https://creativecommons.org/publicdomain/zero/1.0/',
312 'icon' => '$wgResourceBasePath/resources/assets/licenses/cc-0.png',
314 'gfdl' => [
315 'url' => 'https://www.gnu.org/copyleft/fdl.html',
316 'icon' => '$wgResourceBasePath/resources/assets/licenses/gnu-fdl.png',
318 'none' => [
319 'url' => '',
320 'icon' => '',
321 'text' => ''
326 * @var HookContainer|null
328 protected $autoExtensionHookContainer;
329 protected array $virtualDomains = [];
331 /** @var TaskFactory|null */
332 private $taskFactory;
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|MessageSpecifier $msg
340 * @param string|int|float ...$params Message parameters
342 abstract public function showMessage( $msg, ...$params );
345 * Same as showMessage(), but for displaying errors
346 * @param string|MessageSpecifier $msg
347 * @param string|int|float ...$params Message parameters
349 abstract public function showError( $msg, ...$params );
352 * Show a message to the installing user by using a Status object
354 abstract public function showStatusMessage( Status $status );
357 * Constructs a Config object that contains configuration settings that should be
358 * overwritten for the installation process.
360 * @since 1.27
362 * @param Config $baseConfig
364 * @return Config The config to use during installation.
366 public static function getInstallerConfig( Config $baseConfig ) {
367 $configOverrides = new HashConfig();
369 // disable (problematic) object cache types explicitly, preserving all other (working) ones
370 // bug T113843
371 $emptyCache = [ 'class' => EmptyBagOStuff::class ];
373 $objectCaches = [
374 CACHE_NONE => $emptyCache,
375 CACHE_DB => $emptyCache,
376 CACHE_ANYTHING => $emptyCache,
377 CACHE_MEMCACHED => $emptyCache,
378 ] + $baseConfig->get( MainConfigNames::ObjectCaches );
380 $configOverrides->set( MainConfigNames::ObjectCaches, $objectCaches );
382 $installerConfig = new MultiConfig( [ $configOverrides, $baseConfig ] );
384 // make sure we use the installer config as the main config
385 $configRegistry = $baseConfig->get( MainConfigNames::ConfigRegistry );
386 $configRegistry['main'] = static function () use ( $installerConfig ) {
387 return $installerConfig;
390 $configOverrides->set( MainConfigNames::ConfigRegistry, $configRegistry );
392 return $installerConfig;
396 * Constructor, always call this from child classes.
398 public function __construct() {
399 $defaultConfig = new GlobalVarConfig(); // all the defaults from config-schema.yaml.
400 $installerConfig = self::getInstallerConfig( $defaultConfig );
402 // Disable all storage services, since we don't have any configuration yet!
403 $lang = $this->getVar( '_UserLang', 'en' );
404 $services = self::disableStorage( $installerConfig, $lang );
406 // Set up ParserOptions
407 $user = RequestContext::getMain()->getUser();
408 $this->parserOptions = new ParserOptions( $user ); // language will be wrong :(
409 // Don't try to access DB before user language is initialised
410 $this->setParserLanguage( $services->getLanguageFactory()->getLanguage( 'en' ) );
412 $this->settings = $this->getDefaultSettings();
414 $this->compiledDBs = [];
415 foreach ( self::getDBTypes() as $type ) {
416 $installer = $this->getDBInstaller( $type );
418 if ( !$installer->isCompiled() ) {
419 continue;
421 $this->compiledDBs[] = $type;
424 $this->parserTitle = Title::newFromText( 'Installer' );
427 private function getDefaultSettings(): array {
428 global $wgLocaltimezone;
430 $ret = $this->internalDefaults;
432 foreach ( self::DEFAULT_VAR_NAMES as $name ) {
433 $var = "wg{$name}";
434 $ret[$var] = MainConfigSchema::getDefaultValue( $name );
437 // Set $wgLocaltimezone to the value of the global, which SetupDynamicConfig.php will have
438 // set to something that is a valid timezone.
439 $ret['wgLocaltimezone'] = $wgLocaltimezone;
441 // Detect $wgServer
442 $server = $this->envGetDefaultServer();
443 if ( $server !== null ) {
444 $ret['wgServer'] = $server;
447 // Detect $IP
448 $ret['IP'] = MW_INSTALL_PATH;
450 return $this->getDefaultSettingsOverrides()
451 + $this->generateKeys()
452 + $this->detectWebPaths()
453 + $ret;
457 * This is overridden by the web installer to provide the detected wgScriptPath
459 * @return array
461 protected function detectWebPaths() {
462 return [];
466 * Override this in a subclass to override the default settings
468 * @since 1.44
469 * @return array
471 protected function getDefaultSettingsOverrides() {
472 return [];
476 * Generate $wgSecretKey and $wgUpgradeKey.
478 * @return string[]
480 private function generateKeys() {
481 $keyLengths = [
482 'wgSecretKey' => 64,
483 'wgUpgradeKey' => 16,
486 $keys = [];
487 foreach ( $keyLengths as $name => $length ) {
488 $keys[$name] = MWCryptRand::generateHex( $length );
490 return $keys;
494 * Reset the global service container and associated global state,
495 * disabling storage, to support pre-installation operation.
497 * @param Config $config Config override
498 * @param string $lang Language code
499 * @return MediaWikiServices
501 public static function disableStorage( Config $config, string $lang ) {
502 global $wgObjectCaches, $wgLang;
504 // Reset all services and inject config overrides.
505 // Reload to re-enable Rdbms, in case of any prior MediaWikiServices::disableStorage()
506 MediaWikiServices::resetGlobalInstance( $config, 'reload' );
508 $mwServices = MediaWikiServices::getInstance();
509 $mwServices->disableStorage();
511 // Disable i18n cache
512 $mwServices->getLocalisationCache()->disableBackend();
514 // Set a fake user.
515 // Note that this will reset the context's language,
516 // so set the user before setting the language.
517 $user = User::newFromId( 0 );
518 StubGlobalUser::setUser( $user );
520 RequestContext::getMain()->setUser( $user );
522 // Don't attempt to load user language options (T126177)
523 // This will be overridden in the web installer with the user-specified language
524 // Ensure $wgLang does not have a reference to a stale LocalisationCache instance
525 // (T241638, T261081)
526 RequestContext::getMain()->setLanguage( $lang );
527 $wgLang = RequestContext::getMain()->getLanguage();
529 // Disable object cache (otherwise CACHE_ANYTHING will try CACHE_DB and
530 // SqlBagOStuff will then throw since we just disabled wfGetDB)
531 $wgObjectCaches = $mwServices->getMainConfig()->get( MainConfigNames::ObjectCaches );
532 return $mwServices;
536 * Get a list of known DB types.
538 * @return array
540 public static function getDBTypes() {
541 return self::$dbTypes;
545 * Do initial checks of the PHP environment. Set variables according to
546 * the observed environment.
548 * It's possible that this may be called under the CLI SAPI, not the SAPI
549 * that the wiki will primarily run under. In that case, the subclass should
550 * initialise variables such as wgScriptPath, before calling this function.
552 * It can already be assumed that a supported PHP version is in use. Under
553 * the web subclass, it can also be assumed that sessions are working.
555 * @return Status
557 public function doEnvironmentChecks() {
558 // PHP version has already been checked by entry scripts
559 // Show message here for information purposes
560 $this->showMessage( 'config-env-php', PHP_VERSION );
562 $good = true;
563 foreach ( $this->envChecks as $check ) {
564 $status = $this->$check();
565 if ( $status === false ) {
566 $good = false;
570 $this->setVar( '_Environment', $good );
572 return $good ? Status::newGood() : Status::newFatal( 'config-env-bad' );
576 * Set a MW configuration variable, or internal installer configuration variable.
578 * @param string $name
579 * @param mixed $value
581 public function setVar( $name, $value ) {
582 $this->settings[$name] = $value;
586 * Get an MW configuration variable, or internal installer configuration variable.
587 * The defaults come from MainConfigSchema.
588 * Installer variables are typically prefixed by an underscore.
590 * @param string $name
591 * @param mixed|null $default
593 * @return mixed
595 public function getVar( $name, $default = null ) {
596 return $this->settings[$name] ?? $default;
600 * Get a list of DBs supported by current PHP setup
602 * @return array
604 public function getCompiledDBs() {
605 return $this->compiledDBs;
609 * Get the DatabaseInstaller class name for this type
611 * @param string $type database type ($wgDBtype)
612 * @return string Class name
613 * @since 1.30
615 public static function getDBInstallerClass( $type ) {
616 return '\\MediaWiki\\Installer\\' . ucfirst( $type ) . 'Installer';
620 * Get an instance of DatabaseInstaller for the specified DB type.
622 * @param mixed $type DB installer for which is needed, false to use default.
624 * @return DatabaseInstaller
626 public function getDBInstaller( $type = false ) {
627 if ( !$type ) {
628 $type = $this->getVar( 'wgDBtype' );
631 $type = strtolower( $type );
633 if ( !isset( $this->dbInstallers[$type] ) ) {
634 $class = self::getDBInstallerClass( $type );
635 $this->dbInstallers[$type] = new $class( $this );
638 return $this->dbInstallers[$type];
642 * Determine if LocalSettings.php exists. If it does, return its variables.
644 * @return array|false
646 public static function getExistingLocalSettings() {
647 $IP = wfDetectInstallPath();
649 // You might be wondering why this is here. Well if you don't do this
650 // then some poorly-formed extensions try to call their own classes
651 // after immediately registering them. We really need to get extension
652 // registration out of the global scope and into a real format.
653 // @see https://phabricator.wikimedia.org/T69440
654 global $wgAutoloadClasses;
655 $wgAutoloadClasses = [];
657 // LocalSettings.php should not call functions, except wfLoadSkin/wfLoadExtensions
658 // Define the required globals here, to ensure, the functions can do it work correctly.
659 // phpcs:ignore MediaWiki.VariableAnalysis.UnusedGlobalVariables
660 global $wgExtensionDirectory, $wgStyleDirectory;
662 // This will also define MW_CONFIG_FILE
663 $lsFile = wfDetectLocalSettingsFile( $IP );
664 // phpcs:ignore Generic.PHP.NoSilencedErrors
665 $lsExists = @file_exists( $lsFile );
667 if ( !$lsExists ) {
668 return false;
671 if ( !str_ends_with( $lsFile, '.php' ) ) {
672 throw new RuntimeException(
673 'The installer cannot yet handle non-php settings files: ' . $lsFile . '. ' .
674 'Use `php maintenance/run.php update` to update an existing installation.'
677 unset( $lsExists );
679 // Extract the defaults into the current scope
680 foreach ( MainConfigSchema::listDefaultValues( 'wg' ) as $var => $value ) {
681 $$var = $value;
684 $wgExtensionDirectory = "$IP/extensions";
685 $wgStyleDirectory = "$IP/skins";
687 // NOTE: To support YAML settings files, this needs to start using SettingsBuilder.
688 // However, as of 1.38, YAML settings files are still experimental and
689 // SettingsBuilder is still unstable. For now, the installer will fail if
690 // the existing settings file is not PHP. The updater should still work though.
691 // NOTE: When adding support for YAML settings file, all references to LocalSettings.php
692 // in localisation messages need to be replaced.
693 // NOTE: This assumes simple variable assignments. More complex setups may involve
694 // settings coming from sub-required and/or functions that assign globals
695 // directly. This is fine here because this isn't used as the "real" include.
696 // It is only used for reading out a small set of variables that the installer
697 // validates and/or displays.
698 require $lsFile;
700 return get_defined_vars();
704 * Get a fake password for sending back to the user in HTML.
705 * This is a security mechanism to avoid compromise of the password in the
706 * event of session ID compromise.
708 * @param string $realPassword
710 * @return string
712 public function getFakePassword( $realPassword ) {
713 return str_repeat( '*', strlen( $realPassword ) );
717 * Set a variable which stores a password, except if the new value is a
718 * fake password in which case leave it as it is.
720 * @param string $name
721 * @param mixed $value
723 public function setPassword( $name, $value ) {
724 if ( !preg_match( '/^\*+$/', $value ) ) {
725 $this->setVar( $name, $value );
730 * Convert wikitext $text to HTML.
732 * This is potentially error prone since many parser features require a complete
733 * installed MW database. The solution is to just not use those features when you
734 * write your messages. This appears to work well enough. Basic formatting and
735 * external links work just fine.
737 * But in case a translator decides to throw in a "#ifexist" or internal link or
738 * whatever, this function is guarded to catch the attempted DB access and to present
739 * some fallback text.
741 * @param string $text
742 * @param bool $lineStart
743 * @return string
745 public function parse( $text, $lineStart = false ) {
746 $parser = MediaWikiServices::getInstance()->getParser();
748 try {
749 $out = $parser->parse( $text, $this->parserTitle, $this->parserOptions, $lineStart );
750 $pipeline = MediaWikiServices::getInstance()->getDefaultOutputPipeline();
751 // TODO T371008 consider if using the Content framework makes sense instead of creating the pipeline
752 $html = $pipeline->run( $out, $this->parserOptions, [
753 'enableSectionEditLinks' => false,
754 'unwrap' => true,
755 ] )->getContentHolderText();
756 $html = Parser::stripOuterParagraph( $html );
757 } catch ( ServiceDisabledException $e ) {
758 $html = '<!--DB access attempted during parse--> ' . htmlspecialchars( $text );
761 return $html;
765 * @return ParserOptions
767 public function getParserOptions() {
768 return $this->parserOptions;
771 public function disableLinkPopups() {
772 // T317647: This ParserOptions method is deprecated; we should be
773 // updating ExternalLinkTarget in the Configuration instead.
774 $this->parserOptions->setExternalLinkTarget( false );
777 public function restoreLinkPopups() {
778 // T317647: This ParserOptions method is deprecated; we should be
779 // updating ExternalLinkTarget in the Configuration instead.
780 global $wgExternalLinkTarget;
781 $this->parserOptions->setExternalLinkTarget( $wgExternalLinkTarget );
785 * Environment check for DB types.
786 * @return bool
788 protected function envCheckDB() {
789 global $wgLang;
790 /** @var string|null $dbType The user-specified database type */
791 $dbType = $this->getVar( 'wgDBtype' );
793 $allNames = [];
795 // Messages: config-type-mysql, config-type-postgres, config-type-sqlite
796 foreach ( self::getDBTypes() as $name ) {
797 $allNames[] = wfMessage( "config-type-$name" )->text();
800 $databases = $this->getCompiledDBs();
802 $databases = array_flip( $databases );
803 $ok = true;
804 foreach ( $databases as $db => $_ ) {
805 $installer = $this->getDBInstaller( $db );
806 $status = $installer->checkPrerequisites();
807 if ( !$status->isGood() ) {
808 if ( !$this instanceof WebInstaller && $db === $dbType ) {
809 // Strictly check the key database type instead of just outputting message
810 // Note: No perform this check run from the web installer, since this method always called by
811 // the welcome page under web installation, so $dbType will always be 'mysql'
812 $ok = false;
814 $this->showStatusMessage( $status );
815 unset( $databases[$db] );
818 $databases = array_flip( $databases );
819 if ( !$databases ) {
820 $this->showError( 'config-no-db', $wgLang->commaList( $allNames ), count( $allNames ) );
821 return false;
823 return $ok;
827 * Check for known PCRE-related compatibility issues.
829 * @note We don't bother checking for Unicode support here. If it were
830 * missing, the parser would probably throw an exception before the
831 * result of this check is shown to the user.
833 * @return bool
835 protected function envCheckPCRE() {
836 // PCRE2 must be compiled using NEWLINE_DEFAULT other than 4 (ANY);
837 // otherwise, it will misidentify UTF-8 trailing byte value 0x85
838 // as a line ending character when in non-UTF mode.
839 if ( preg_match( '/^b.*c$/', 'bÄ…c' ) === 0 ) {
840 $this->showError( 'config-pcre-invalid-newline' );
841 return false;
843 return true;
847 * Environment check for available memory.
848 * @return bool
850 protected function envCheckMemory() {
851 $limit = ini_get( 'memory_limit' );
853 if ( !$limit || $limit == -1 ) {
854 return true;
857 $n = wfShorthandToInteger( $limit );
859 if ( $n < $this->minMemorySize * 1024 * 1024 ) {
860 $newLimit = "{$this->minMemorySize}M";
862 if ( ini_set( "memory_limit", $newLimit ) === false ) {
863 $this->showMessage( 'config-memory-bad', $limit );
864 } else {
865 $this->showMessage( 'config-memory-raised', $limit, $newLimit );
866 $this->setVar( '_RaiseMemory', true );
870 return true;
874 * Environment check for compiled object cache types.
876 protected function envCheckCache() {
877 $caches = [];
878 foreach ( $this->objectCaches as $name => $function ) {
879 if ( function_exists( $function ) ) {
880 $caches[$name] = true;
884 if ( !$caches ) {
885 $this->showMessage( 'config-no-cache-apcu' );
888 $this->setVar( '_Caches', $caches );
892 * Scare user to death if they have mod_security or mod_security2
893 * @return bool
895 protected function envCheckModSecurity() {
896 if ( self::apacheModulePresent( 'mod_security' )
897 || self::apacheModulePresent( 'mod_security2' ) ) {
898 $this->showMessage( 'config-mod-security' );
901 return true;
905 * Search for GNU diff3.
906 * @return bool
908 protected function envCheckDiff3() {
909 $names = [ "gdiff3", "diff3" ];
910 if ( wfIsWindows() ) {
911 $names[] = 'diff3.exe';
913 $versionInfo = [ '--version', 'GNU diffutils' ];
915 $diff3 = ExecutableFinder::findInDefaultPaths( $names, $versionInfo );
917 if ( $diff3 ) {
918 $this->setVar( 'wgDiff3', $diff3 );
919 } else {
920 $this->setVar( 'wgDiff3', false );
921 $this->showMessage( 'config-diff3-bad' );
924 return true;
928 * Environment check for ImageMagick and GD.
929 * @return bool
931 protected function envCheckGraphics() {
932 $names = wfIsWindows() ? 'convert.exe' : 'convert';
933 $versionInfo = [ '-version', 'ImageMagick' ];
934 $convert = ExecutableFinder::findInDefaultPaths( $names, $versionInfo );
936 $this->setVar( 'wgImageMagickConvertCommand', '' );
937 if ( $convert ) {
938 $this->setVar( 'wgImageMagickConvertCommand', $convert );
939 $this->showMessage( 'config-imagemagick', $convert );
940 } elseif ( function_exists( 'imagejpeg' ) ) {
941 $this->showMessage( 'config-gd' );
942 } else {
943 $this->showMessage( 'config-no-scaling' );
946 return true;
950 * Search for git.
952 * @since 1.22
953 * @return bool
955 protected function envCheckGit() {
956 $names = wfIsWindows() ? 'git.exe' : 'git';
957 $versionInfo = [ '--version', 'git version' ];
959 $git = ExecutableFinder::findInDefaultPaths( $names, $versionInfo );
961 if ( $git ) {
962 $this->setVar( 'wgGitBin', $git );
963 $this->showMessage( 'config-git', $git );
964 } else {
965 $this->setVar( 'wgGitBin', false );
966 $this->showMessage( 'config-git-bad' );
969 return true;
973 * Environment check to inform user which server we've assumed.
975 * @return bool
977 protected function envCheckServer() {
978 $server = $this->envGetDefaultServer();
979 if ( $server !== null ) {
980 $this->showMessage( 'config-using-server', $server );
982 return true;
986 * Environment check to inform user which paths we've assumed.
988 * @return bool
990 protected function envCheckPath() {
991 $this->showMessage(
992 'config-using-uri',
993 $this->getVar( 'wgServer' ),
994 $this->getVar( 'wgScriptPath' )
996 return true;
1000 * Environment check for the permissions of the uploads directory
1001 * @return bool
1003 protected function envCheckUploadsDirectory() {
1004 global $IP;
1006 $dir = $IP . '/images/';
1007 $url = $this->getVar( 'wgServer' ) . $this->getVar( 'wgScriptPath' ) . '/images/';
1008 $safe = !$this->dirIsExecutable( $dir, $url );
1010 if ( !$safe ) {
1011 $this->showMessage( 'config-uploads-not-safe', $dir );
1014 return true;
1017 protected function envCheckUploadsServerResponse() {
1018 $url = $this->getVar( 'wgServer' ) . $this->getVar( 'wgScriptPath' ) . '/images/README';
1019 $httpRequestFactory = MediaWikiServices::getInstance()->getHttpRequestFactory();
1020 $status = null;
1022 $req = $httpRequestFactory->create(
1023 $url,
1025 'method' => 'GET',
1026 'timeout' => 3,
1027 'followRedirects' => true
1029 __METHOD__
1031 try {
1032 $status = $req->execute();
1033 } catch ( Exception $e ) {
1034 // HttpRequestFactory::get can throw with allow_url_fopen = false and no curl
1035 // extension.
1038 if ( !$status || !$status->isGood() ) {
1039 $this->showMessage( 'config-uploads-security-requesterror', 'X-Content-Type-Options: nosniff' );
1040 return true;
1043 $headerValue = $req->getResponseHeader( 'X-Content-Type-Options' ) ?? '';
1044 $responseList = Header::splitList( $headerValue );
1045 if ( !in_array( 'nosniff', $responseList, true ) ) {
1046 $this->showMessage( 'config-uploads-security-headers', 'X-Content-Type-Options: nosniff' );
1049 return true;
1053 * Checks if we're running on 64 bit or not. 32 bit is becoming increasingly
1054 * hard to support, so let's at least warn people.
1056 * @return bool
1058 protected function envCheck64Bit() {
1059 if ( PHP_INT_SIZE == 4 ) {
1060 $this->showMessage( 'config-using-32bit' );
1063 return true;
1067 * Check and display the libicu and Unicode versions
1069 protected function envCheckLibicu() {
1070 $unicodeVersion = implode( '.', array_slice( IntlChar::getUnicodeVersion(), 0, 3 ) );
1071 $this->showMessage( 'config-env-icu', INTL_ICU_VERSION, $unicodeVersion );
1075 * Helper function to be called from getDefaultSettings()
1076 * @return string
1078 abstract protected function envGetDefaultServer();
1081 * Checks if scripts located in the given directory can be executed via the given URL.
1083 * Used only by environment checks.
1084 * @param string $dir
1085 * @param string $url
1086 * @return bool|int|string
1088 public function dirIsExecutable( $dir, $url ) {
1089 $scriptTypes = [
1090 'php' => [
1091 "<?php echo 'exec';",
1092 "#!/var/env php\n<?php echo 'exec';",
1096 // it would be good to check other popular languages here, but it'll be slow.
1097 // TODO no need to have a loop if there is going to only be one script type
1099 $httpRequestFactory = MediaWikiServices::getInstance()->getHttpRequestFactory();
1101 AtEase::suppressWarnings();
1103 foreach ( $scriptTypes as $ext => $contents ) {
1104 foreach ( $contents as $source ) {
1105 $file = 'exectest.' . $ext;
1107 if ( !file_put_contents( $dir . $file, $source ) ) {
1108 break;
1111 try {
1112 $text = $httpRequestFactory->get(
1113 $url . $file,
1114 [ 'timeout' => 3 ],
1115 __METHOD__
1117 } catch ( Exception $e ) {
1118 // HttpRequestFactory::get can throw with allow_url_fopen = false and no curl
1119 // extension.
1120 $text = null;
1122 unlink( $dir . $file );
1124 if ( $text == 'exec' ) {
1125 AtEase::restoreWarnings();
1127 return $ext;
1132 AtEase::restoreWarnings();
1134 return false;
1138 * Checks for presence of an Apache module. Works only if PHP is running as an Apache module, too.
1140 * @param string $moduleName Name of module to check.
1141 * @return bool
1143 public static function apacheModulePresent( $moduleName ) {
1144 if ( function_exists( 'apache_get_modules' ) && in_array( $moduleName, apache_get_modules() ) ) {
1145 return true;
1147 // try it the hard way
1148 ob_start();
1149 phpinfo( INFO_MODULES );
1150 $info = ob_get_clean();
1152 return strpos( $info, $moduleName ) !== false;
1156 * ParserOptions are constructed before we determined the language, so fix it
1158 * @param Language $lang
1160 public function setParserLanguage( $lang ) {
1161 $this->parserOptions->setTargetLanguage( $lang );
1162 $this->parserOptions->setUserLang( $lang );
1166 * Overridden by WebInstaller to provide lastPage parameters.
1167 * @param string $page
1168 * @return string
1170 protected function getDocUrl( $page ) {
1171 return "{$_SERVER['PHP_SELF']}?page=" . urlencode( $page );
1175 * Find extensions or skins in a subdirectory of $IP.
1176 * Returns an array containing the value for 'Name' for each found extension.
1178 * @param string $directory Directory to search in, relative to $IP, must be either "extensions"
1179 * or "skins"
1180 * @return Status An object containing an error list. If there were no errors, an associative
1181 * array of information about the extension can be found in $status->value.
1183 public function findExtensions( $directory = 'extensions' ) {
1184 switch ( $directory ) {
1185 case 'extensions':
1186 return $this->findExtensionsByType( 'extension', 'extensions' );
1187 case 'skins':
1188 return $this->findExtensionsByType( 'skin', 'skins' );
1189 default:
1190 throw new InvalidArgumentException( "Invalid extension type" );
1195 * Find extensions or skins, and return an array containing the value for 'Name' for each found
1196 * extension.
1198 * @param string $type Either "extension" or "skin"
1199 * @param string $directory Directory to search in, relative to $IP
1200 * @return Status An object containing an error list. If there were no errors, an associative
1201 * array of information about the extension can be found in $status->value.
1203 protected function findExtensionsByType( $type = 'extension', $directory = 'extensions' ) {
1204 if ( $this->getVar( 'IP' ) === null ) {
1205 return Status::newGood( [] );
1208 $extDir = $this->getVar( 'IP' ) . '/' . $directory;
1209 if ( !is_readable( $extDir ) || !is_dir( $extDir ) ) {
1210 return Status::newGood( [] );
1213 $dh = opendir( $extDir );
1214 $exts = [];
1215 $status = new Status;
1216 // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
1217 while ( ( $file = readdir( $dh ) ) !== false ) {
1218 // skip non-dirs and hidden directories
1219 if ( !is_dir( "$extDir/$file" ) || $file[0] === '.' ) {
1220 continue;
1222 $extStatus = $this->getExtensionInfo( $type, $directory, $file );
1223 if ( $extStatus->isOK() ) {
1224 $exts[$file] = $extStatus->value;
1225 } elseif ( $extStatus->hasMessage( 'config-extension-not-found' ) ) {
1226 // (T225512) The directory is not actually an extension. Downgrade to warning.
1227 $status->warning( 'config-extension-not-found', $file );
1228 } else {
1229 $status->merge( $extStatus );
1232 closedir( $dh );
1233 uksort( $exts, 'strnatcasecmp' );
1235 $status->value = $exts;
1237 return $status;
1241 * @param string $type Either "extension" or "skin"
1242 * @param string $parentRelPath The parent directory relative to $IP
1243 * @param string $name The extension or skin name
1244 * @return Status An object containing an error list. If there were no errors, an associative
1245 * array of information about the extension can be found in $status->value.
1247 protected function getExtensionInfo( $type, $parentRelPath, $name ) {
1248 if ( $this->getVar( 'IP' ) === null ) {
1249 throw new RuntimeException( 'Cannot find extensions since the IP variable is not yet set' );
1251 if ( $type !== 'extension' && $type !== 'skin' ) {
1252 throw new InvalidArgumentException( "Invalid extension type" );
1254 $absDir = $this->getVar( 'IP' ) . "/$parentRelPath/$name";
1255 $relDir = "../$parentRelPath/$name";
1256 if ( !is_dir( $absDir ) ) {
1257 return Status::newFatal( 'config-extension-not-found', $name );
1259 $jsonFile = $type . '.json';
1260 $fullJsonFile = "$absDir/$jsonFile";
1261 $isJson = file_exists( $fullJsonFile );
1262 $isPhp = false;
1263 if ( !$isJson ) {
1264 // Only fallback to PHP file if JSON doesn't exist
1265 $fullPhpFile = "$absDir/$name.php";
1266 $isPhp = file_exists( $fullPhpFile );
1268 if ( !$isJson && !$isPhp ) {
1269 return Status::newFatal( 'config-extension-not-found', $name );
1272 // Extension exists. Now see if there are screenshots
1273 $info = [];
1274 if ( is_dir( "$absDir/screenshots" ) ) {
1275 $paths = glob( "$absDir/screenshots/*.png" );
1276 foreach ( $paths as $path ) {
1277 $info['screenshots'][] = str_replace( $absDir, $relDir, $path );
1281 if ( $isJson ) {
1282 $jsonStatus = $this->readExtension( $fullJsonFile );
1283 if ( !$jsonStatus->isOK() ) {
1284 return $jsonStatus;
1286 $info += $jsonStatus->value;
1289 return Status::newGood( $info );
1293 * @param string $fullJsonFile
1294 * @param array $extDeps
1295 * @param array $skinDeps
1297 * @return Status On success, an array of extension information is in $status->value. On
1298 * failure, the Status object will have an error list.
1300 private function readExtension( $fullJsonFile, $extDeps = [], $skinDeps = [] ) {
1301 $load = [
1302 $fullJsonFile => 1
1304 if ( $extDeps ) {
1305 $extDir = $this->getVar( 'IP' ) . '/extensions';
1306 foreach ( $extDeps as $dep ) {
1307 $fname = "$extDir/$dep/extension.json";
1308 if ( !file_exists( $fname ) ) {
1309 return Status::newFatal( 'config-extension-not-found', $dep );
1311 $load[$fname] = 1;
1314 if ( $skinDeps ) {
1315 $skinDir = $this->getVar( 'IP' ) . '/skins';
1316 foreach ( $skinDeps as $dep ) {
1317 $fname = "$skinDir/$dep/skin.json";
1318 if ( !file_exists( $fname ) ) {
1319 return Status::newFatal( 'config-extension-not-found', $dep );
1321 $load[$fname] = 1;
1324 $registry = new ExtensionRegistry();
1325 try {
1326 $info = $registry->readFromQueue( $load );
1327 } catch ( ExtensionDependencyError $e ) {
1328 if ( $e->incompatibleCore || $e->incompatibleSkins
1329 || $e->incompatibleExtensions
1331 // If something is incompatible with a dependency, we have no real
1332 // option besides skipping it
1333 return Status::newFatal( 'config-extension-dependency',
1334 basename( dirname( $fullJsonFile ) ), $e->getMessage() );
1335 } elseif ( $e->missingExtensions || $e->missingSkins ) {
1336 // There's an extension missing in the dependency tree,
1337 // so add those to the dependency list and try again
1338 $status = $this->readExtension(
1339 $fullJsonFile,
1340 array_merge( $extDeps, $e->missingExtensions ),
1341 array_merge( $skinDeps, $e->missingSkins )
1343 if ( !$status->isOK() && !$status->hasMessage( 'config-extension-dependency' ) ) {
1344 $status = Status::newFatal( 'config-extension-dependency',
1345 basename( dirname( $fullJsonFile ) ), $status->getMessage() );
1347 return $status;
1349 // Some other kind of dependency error?
1350 return Status::newFatal( 'config-extension-dependency',
1351 basename( dirname( $fullJsonFile ) ), $e->getMessage() );
1353 $ret = [];
1354 // The order of credits will be the order of $load,
1355 // so the first extension is the one we want to load,
1356 // everything else is a dependency
1357 $i = 0;
1358 foreach ( $info['credits'] as $credit ) {
1359 $i++;
1360 if ( $i == 1 ) {
1361 // Extension we want to load
1362 continue;
1364 $type = basename( $credit['path'] ) === 'skin.json' ? 'skins' : 'extensions';
1365 $ret['requires'][$type][] = $credit['name'];
1367 $credits = array_values( $info['credits'] )[0];
1368 if ( isset( $credits['url'] ) ) {
1369 $ret['url'] = $credits['url'];
1371 $ret['type'] = $credits['type'];
1373 return Status::newGood( $ret );
1377 * Returns a default value to be used for $wgDefaultSkin: normally the DefaultSkin from
1378 * config-schema.yaml, but will fall back to another if the default skin is missing
1379 * and some other one is present instead.
1381 * @param string[] $skinNames Names of installed skins.
1382 * @return string
1384 public function getDefaultSkin( array $skinNames ) {
1385 $defaultSkin = $GLOBALS['wgDefaultSkin'];
1387 if ( in_array( 'vector', $skinNames ) ) {
1388 $skinNames[] = 'vector-2022';
1391 // T346332: Minerva skin uses different name from its directory name
1392 if ( in_array( 'minervaneue', $skinNames ) ) {
1393 $minervaNeue = array_search( 'minervaneue', $skinNames );
1394 $skinNames[$minervaNeue] = 'minerva';
1397 if ( !$skinNames || in_array( $defaultSkin, $skinNames ) ) {
1398 return $defaultSkin;
1399 } else {
1400 return $skinNames[0];
1405 * Get a list of tasks to do
1407 * There must be a config-install-$name message defined per step, which will
1408 * be shown on install.
1410 * @return TaskList
1412 protected function getTaskList() {
1413 $taskList = new TaskList;
1414 $taskFactory = $this->getTaskFactory();
1415 $taskFactory->registerMainTasks( $taskList, TaskFactory::PROFILE_INSTALLER );
1417 // Add any steps added by overrides
1418 foreach ( $this->extraInstallSteps as $requirement => $steps ) {
1419 foreach ( $steps as $spec ) {
1420 if ( $requirement !== 'BEGINNING' ) {
1421 $spec += [ 'after' => $requirement ];
1423 $taskList->add( $taskFactory->create( $spec ) );
1427 return $taskList;
1430 private function getTaskFactory() {
1431 if ( $this->taskFactory === null ) {
1432 $this->taskFactory = new TaskFactory(
1433 MediaWikiServices::getInstance()->getObjectFactory(),
1434 $this->getDBInstaller()
1437 return $this->taskFactory;
1441 * Actually perform the installation.
1443 * @param callable $startCB A callback array for the beginning of each step
1444 * @param callable $endCB A callback array for the end of each step
1446 * @return Status
1448 public function performInstallation( $startCB, $endCB ) {
1449 $tasks = $this->getTaskList();
1451 $taskRunner = new TaskRunner( $tasks, $this->getTaskFactory(),
1452 TaskFactory::PROFILE_INSTALLER );
1453 $taskRunner->addTaskStartListener( $startCB );
1454 $taskRunner->addTaskEndListener( $endCB );
1456 $status = $taskRunner->execute();
1457 if ( $status->isOK() ) {
1458 $this->showMessage(
1459 'config-install-db-success'
1461 $this->setVar( '_InstallDone', true );
1464 return $status;
1468 * Restore services that have been redefined in the early stage of installation
1470 protected function restoreServices() {
1471 $provider = $this->getTaskFactory()->create(
1472 [ 'class' => RestoredServicesProvider::class ] );
1473 $provider->execute();
1477 * Override the necessary bits of the config to run an installation.
1479 public static function overrideConfig( SettingsBuilder $settings ) {
1480 // Use PHP's built-in session handling, since MediaWiki's
1481 // SessionHandler can't work before we have an object cache set up.
1482 if ( !defined( 'MW_NO_SESSION_HANDLER' ) ) {
1483 define( 'MW_NO_SESSION_HANDLER', 1 );
1486 $settings->overrideConfigValues( [
1488 // Don't access the database
1489 MainConfigNames::UseDatabaseMessages => false,
1491 // Don't cache langconv tables
1492 MainConfigNames::LanguageConverterCacheType => CACHE_NONE,
1494 // Debug-friendly
1495 MainConfigNames::ShowExceptionDetails => true,
1496 MainConfigNames::ShowHostnames => true,
1498 // Don't break forms
1499 MainConfigNames::ExternalLinkTarget => '_blank',
1501 // Allow multiple ob_flush() calls
1502 MainConfigNames::DisableOutputCompression => true,
1504 // Use a sensible cookie prefix (not my_wiki)
1505 MainConfigNames::CookiePrefix => 'mw_installer',
1507 // Some of the environment checks make shell requests, remove limits
1508 MainConfigNames::MaxShellMemory => 0,
1510 // Override the default CookieSessionProvider with a dummy
1511 // implementation that won't stomp on PHP's cookies.
1512 MainConfigNames::SessionProviders => [
1514 'class' => InstallerSessionProvider::class,
1515 'args' => [ [
1516 'priority' => 1,
1521 // Don't use the DB as the main stash
1522 MainConfigNames::MainStash => CACHE_NONE,
1524 // Don't try to use any object cache for SessionManager either.
1525 MainConfigNames::SessionCacheType => CACHE_NONE,
1527 // Set a dummy $wgServer to bypass the check in Setup.php, the
1528 // web installer will automatically detect it and not use this value.
1529 MainConfigNames::Server => 'https://🌻.invalid',
1530 ] );
1534 * Add an installation step following the given step.
1536 * @param array $callback A valid installation callback array, in this form:
1537 * [ 'name' => 'some-unique-name', 'callback' => [ $obj, 'function' ] ];
1538 * @param string $findStep The step to find. Omit to put the step at the beginning
1540 public function addInstallStep( $callback, $findStep = 'BEGINNING' ) {
1541 $this->extraInstallSteps[$findStep][] = $callback;
1545 * Disable the time limit for execution.
1546 * Some long-running pages (Install, Upgrade) will want to do this
1548 protected function disableTimeLimit() {
1549 AtEase::suppressWarnings();
1550 set_time_limit( 0 );
1551 AtEase::restoreWarnings();