1<?php
2/**
3 * Core installer web interface.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup Installer
22 */
23
24use MediaWiki\MediaWikiServices;
25
26/**
27 * Class for the core installer web interface.
28 *
29 * @ingroup Installer
30 * @since 1.17
31 */
32class WebInstaller extends Installer {
33
34	/**
35	 * @var WebInstallerOutput
36	 */
37	public $output;
38
39	/**
40	 * WebRequest object.
41	 *
42	 * @var WebRequest
43	 */
44	public $request;
45
46	/**
47	 * Cached session array.
48	 *
49	 * @var array[]
50	 */
51	protected $session;
52
53	/**
54	 * Captured PHP error text. Temporary.
55	 *
56	 * @var string[]
57	 */
58	protected $phpErrors;
59
60	/**
61	 * The main sequence of page names. These will be displayed in turn.
62	 *
63	 * To add a new installer page:
64	 *    * Add it to this WebInstaller::$pageSequence property
65	 *    * Add a "config-page-<name>" message
66	 *    * Add a "WebInstaller<name>" class
67	 *
68	 * @var string[]
69	 */
70	public $pageSequence = [
71		'Language',
72		'ExistingWiki',
73		'Welcome',
74		'DBConnect',
75		'Upgrade',
76		'DBSettings',
77		'Name',
78		'Options',
79		'Install',
80		'Complete',
81	];
82
83	/**
84	 * Out of sequence pages, selectable by the user at any time.
85	 *
86	 * @var string[]
87	 */
88	protected $otherPages = [
89		'Restart',
90		'ReleaseNotes',
91		'Copying',
92		'UpgradeDoc', // Can't use Upgrade due to Upgrade step
93	];
94
95	/**
96	 * Array of pages which have declared that they have been submitted, have validated
97	 * their input, and need no further processing.
98	 *
99	 * @var bool[]
100	 */
101	protected $happyPages;
102
103	/**
104	 * List of "skipped" pages. These are pages that will automatically continue
105	 * to the next page on any GET request. To avoid breaking the "back" button,
106	 * they need to be skipped during a back operation.
107	 *
108	 * @var bool[]
109	 */
110	protected $skippedPages;
111
112	/**
113	 * Flag indicating that session data may have been lost.
114	 *
115	 * @var bool
116	 */
117	public $showSessionWarning = false;
118
119	/**
120	 * Numeric index of the page we're on
121	 *
122	 * @var int
123	 */
124	protected $tabIndex = 1;
125
126	/**
127	 * Numeric index of the help box
128	 *
129	 * @var int
130	 */
131	protected $helpBoxId = 1;
132
133	/**
134	 * Name of the page we're on
135	 *
136	 * @var string
137	 */
138	protected $currentPageName;
139
140	/**
141	 * @param WebRequest $request
142	 */
143	public function __construct( WebRequest $request ) {
144		parent::__construct();
145		$this->output = new WebInstallerOutput( $this );
146		$this->request = $request;
147	}
148
149	/**
150	 * Main entry point.
151	 *
152	 * @param array[] $session Initial session array
153	 *
154	 * @return array[] New session array
155	 */
156	public function execute( array $session ) {
157		$this->session = $session;
158
159		if ( isset( $session['settings'] ) ) {
160			$this->settings = $session['settings'] + $this->settings;
161			// T187586 MediaWikiServices works with globals
162			foreach ( $this->settings as $key => $val ) {
163				$GLOBALS[$key] = $val;
164			}
165		}
166
167		$this->setupLanguage();
168
169		if ( ( $this->getVar( '_InstallDone' ) || $this->getVar( '_UpgradeDone' ) )
170			&& $this->request->getVal( 'localsettings' )
171		) {
172			$this->outputLS();
173			return $this->session;
174		}
175
176		$isCSS = $this->request->getVal( 'css' );
177		if ( $isCSS ) {
178			$this->outputCss();
179			return $this->session;
180		}
181
182		$this->happyPages = $session['happyPages'] ?? [];
183
184		$this->skippedPages = $session['skippedPages'] ?? [];
185
186		$lowestUnhappy = $this->getLowestUnhappy();
187
188		# Special case for Creative Commons partner chooser box.
189		if ( $this->request->getVal( 'SubmitCC' ) ) {
190			/** @var WebInstallerOptions $page */
191			$page = $this->getPageByName( 'Options' );
192			'@phan-var WebInstallerOptions $page';
193			$this->output->useShortHeader();
194			$this->output->allowFrames();
195			$page->submitCC();
196
197			return $this->finish();
198		}
199
200		if ( $this->request->getVal( 'ShowCC' ) ) {
201			/** @var WebInstallerOptions $page */
202			$page = $this->getPageByName( 'Options' );
203			'@phan-var WebInstallerOptions $page';
204			$this->output->useShortHeader();
205			$this->output->allowFrames();
206			$this->output->addHTML( $page->getCCDoneBox() );
207
208			return $this->finish();
209		}
210
211		# Get the page name.
212		$pageName = $this->request->getVal( 'page' );
213
214		if ( in_array( $pageName, $this->otherPages ) ) {
215			# Out of sequence
216			$pageId = false;
217			$page = $this->getPageByName( $pageName );
218		} else {
219			# Main sequence
220			if ( !$pageName || !in_array( $pageName, $this->pageSequence ) ) {
221				$pageId = $lowestUnhappy;
222			} else {
223				$pageId = array_search( $pageName, $this->pageSequence );
224			}
225
226			# If necessary, move back to the lowest-numbered unhappy page
227			if ( $pageId > $lowestUnhappy ) {
228				$pageId = $lowestUnhappy;
229				if ( $lowestUnhappy == 0 ) {
230					# Knocked back to start, possible loss of session data.
231					$this->showSessionWarning = true;
232				}
233			}
234
235			$pageName = $this->pageSequence[$pageId];
236			$page = $this->getPageByName( $pageName );
237		}
238
239		# If a back button was submitted, go back without submitting the form data.
240		if ( $this->request->wasPosted() && $this->request->getBool( 'submit-back' ) ) {
241			if ( $this->request->getVal( 'lastPage' ) ) {
242				$nextPage = $this->request->getVal( 'lastPage' );
243			} elseif ( $pageId !== false ) {
244				# Main sequence page
245				# Skip the skipped pages
246				$nextPageId = $pageId;
247
248				do {
249					$nextPageId--;
250					$nextPage = $this->pageSequence[$nextPageId];
251				} while ( isset( $this->skippedPages[$nextPage] ) );
252			} else {
253				$nextPage = $this->pageSequence[$lowestUnhappy];
254			}
255
256			$this->output->redirect( $this->getUrl( [ 'page' => $nextPage ] ) );
257
258			return $this->finish();
259		}
260
261		# Execute the page.
262		$this->currentPageName = $page->getName();
263		$this->startPageWrapper( $pageName );
264
265		if ( $page->isSlow() ) {
266			$this->disableTimeLimit();
267		}
268
269		$result = $page->execute();
270
271		$this->endPageWrapper();
272
273		if ( $result == 'skip' ) {
274			# Page skipped without explicit submission.
275			# Skip it when we click "back" so that we don't just go forward again.
276			$this->skippedPages[$pageName] = true;
277			$result = 'continue';
278		} else {
279			unset( $this->skippedPages[$pageName] );
280		}
281
282		# If it was posted, the page can request a continue to the next page.
283		if ( $result === 'continue' && !$this->output->headerDone() ) {
284			if ( $pageId !== false ) {
285				$this->happyPages[$pageId] = true;
286			}
287
288			$lowestUnhappy = $this->getLowestUnhappy();
289
290			if ( $this->request->getVal( 'lastPage' ) ) {
291				$nextPage = $this->request->getVal( 'lastPage' );
292			} elseif ( $pageId !== false ) {
293				$nextPage = $this->pageSequence[$pageId + 1];
294			} else {
295				$nextPage = $this->pageSequence[$lowestUnhappy];
296			}
297
298			if ( array_search( $nextPage, $this->pageSequence ) > $lowestUnhappy ) {
299				$nextPage = $this->pageSequence[$lowestUnhappy];
300			}
301
302			$this->output->redirect( $this->getUrl( [ 'page' => $nextPage ] ) );
303		}
304
305		return $this->finish();
306	}
307
308	/**
309	 * Find the next page in sequence that hasn't been completed
310	 * @return int
311	 */
312	public function getLowestUnhappy() {
313		if ( count( $this->happyPages ) == 0 ) {
314			return 0;
315		} else {
316			return max( array_keys( $this->happyPages ) ) + 1;
317		}
318	}
319
320	/**
321	 * Start the PHP session. This may be called before execute() to start the PHP session.
322	 *
323	 * @throws Exception
324	 * @return bool
325	 */
326	public function startSession() {
327		if ( wfIniGetBool( 'session.auto_start' ) || session_id() ) {
328			// Done already
329			return true;
330		}
331
332		// Use secure cookies if we are on HTTPS
333		$options = [];
334		if ( $this->request->getProtocol() === 'https' ) {
335			$options['cookie_secure'] = '1';
336		}
337
338		$this->phpErrors = [];
339		set_error_handler( [ $this, 'errorHandler' ] );
340		try {
341			session_name( 'mw_installer_session' );
342			session_start( $options );
343		} catch ( Exception $e ) {
344			restore_error_handler();
345			throw $e;
346		}
347		restore_error_handler();
348
349		if ( $this->phpErrors ) {
350			return false;
351		}
352
353		return true;
354	}
355
356	/**
357	 * Get a hash of data identifying this MW installation.
358	 *
359	 * This is used by mw-config/index.php to prevent multiple installations of MW
360	 * on the same cookie domain from interfering with each other.
361	 *
362	 * @return string
363	 */
364	public function getFingerprint() {
365		// Get the base URL of the installation
366		$url = $this->request->getFullRequestURL();
367		if ( preg_match( '!^(.*\?)!', $url, $m ) ) {
368			// Trim query string
369			$url = $m[1];
370		}
371		if ( preg_match( '!^(.*)/[^/]*/[^/]*$!', $url, $m ) ) {
372			// This... seems to try to get the base path from
373			// the /mw-config/index.php. Kinda scary though?
374			$url = $m[1];
375		}
376
377		return md5( serialize( [
378			'local path' => dirname( __DIR__ ),
379			'url' => $url,
380			'version' => MW_VERSION
381		] ) );
382	}
383
384	/**
385	 * Show an error message in a box. Parameters are like wfMessage(), or
386	 * alternatively, pass a Message object in.
387	 * @param string|Message $msg
388	 * @param mixed ...$params
389	 */
390	public function showError( $msg, ...$params ) {
391		if ( !( $msg instanceof Message ) ) {
392			$msg = wfMessage(
393				$msg,
394				array_map( 'htmlspecialchars', $params )
395			);
396		}
397		$text = $msg->useDatabase( false )->parse();
398		$box = Html::errorBox( $text, '', 'config-error-box' );
399		$this->output->addHTML( $box );
400	}
401
402	/**
403	 * Temporary error handler for session start debugging.
404	 *
405	 * @param int $errno Unused
406	 * @param string $errstr
407	 */
408	public function errorHandler( $errno, $errstr ) {
409		$this->phpErrors[] = $errstr;
410	}
411
412	/**
413	 * Clean up from execute()
414	 *
415	 * @return array[]
416	 */
417	public function finish() {
418		$this->output->output();
419
420		$this->session['happyPages'] = $this->happyPages;
421		$this->session['skippedPages'] = $this->skippedPages;
422		$this->session['settings'] = $this->settings;
423
424		return $this->session;
425	}
426
427	/**
428	 * We're restarting the installation, reset the session, happyPages, etc
429	 */
430	public function reset() {
431		$this->session = [];
432		$this->happyPages = [];
433		$this->settings = [];
434	}
435
436	/**
437	 * Get a URL for submission back to the same script.
438	 *
439	 * @param string[] $query
440	 *
441	 * @return string
442	 */
443	public function getUrl( $query = [] ) {
444		$url = $this->request->getRequestURL();
445		# Remove existing query
446		$url = preg_replace( '/\?.*$/', '', $url );
447
448		if ( $query ) {
449			$url .= '?' . wfArrayToCgi( $query );
450		}
451
452		return $url;
453	}
454
455	/**
456	 * Get a WebInstallerPage by name.
457	 *
458	 * @param string $pageName
459	 * @return WebInstallerPage
460	 */
461	public function getPageByName( $pageName ) {
462		$pageClass = 'WebInstaller' . $pageName;
463
464		return new $pageClass( $this );
465	}
466
467	/**
468	 * Get a session variable.
469	 *
470	 * @param string $name
471	 * @param array|null $default
472	 *
473	 * @return array
474	 */
475	public function getSession( $name, $default = null ) {
476		return $this->session[$name] ?? $default;
477	}
478
479	/**
480	 * Set a session variable.
481	 *
482	 * @param string $name Key for the variable
483	 * @param mixed $value
484	 */
485	public function setSession( $name, $value ) {
486		$this->session[$name] = $value;
487	}
488
489	/**
490	 * Get the next tabindex attribute value.
491	 *
492	 * @return int
493	 */
494	public function nextTabIndex() {
495		return $this->tabIndex++;
496	}
497
498	/**
499	 * Initializes language-related variables.
500	 */
501	public function setupLanguage() {
502		global $wgLang, $wgLanguageCode;
503
504		if ( $this->getSession( 'test' ) === null && !$this->request->wasPosted() ) {
505			$wgLanguageCode = $this->getAcceptLanguage();
506			$wgLang = MediaWikiServices::getInstance()->getLanguageFactory()
507				->getLanguage( $wgLanguageCode );
508			RequestContext::getMain()->setLanguage( $wgLang );
509			$this->setVar( 'wgLanguageCode', $wgLanguageCode );
510			$this->setVar( '_UserLang', $wgLanguageCode );
511		} else {
512			$wgLanguageCode = $this->getVar( 'wgLanguageCode' );
513		}
514	}
515
516	/**
517	 * Retrieves MediaWiki language from Accept-Language HTTP header.
518	 *
519	 * @return string
520	 */
521	public function getAcceptLanguage() {
522		global $wgLanguageCode, $wgRequest;
523
524		$mwLanguages = MediaWikiServices::getInstance()
525			->getLanguageNameUtils()
526			->getLanguageNames( null, 'mwfile' );
527		$headerLanguages = array_keys( $wgRequest->getAcceptLang() );
528
529		foreach ( $headerLanguages as $lang ) {
530			if ( isset( $mwLanguages[$lang] ) ) {
531				return $lang;
532			}
533		}
534
535		return $wgLanguageCode;
536	}
537
538	/**
539	 * Called by execute() before page output starts, to show a page list.
540	 *
541	 * @param string $currentPageName
542	 */
543	private function startPageWrapper( $currentPageName ) {
544		$s = "<div class=\"config-page-wrapper\">\n";
545		$s .= "<div class=\"config-page\">\n";
546		$s .= "<div class=\"config-page-list\"><ul>\n";
547		$lastHappy = -1;
548
549		foreach ( $this->pageSequence as $id => $pageName ) {
550			$happy = !empty( $this->happyPages[$id] );
551			$s .= $this->getPageListItem(
552				$pageName,
553				$happy || $lastHappy == $id - 1,
554				$currentPageName
555			);
556
557			if ( $happy ) {
558				$lastHappy = $id;
559			}
560		}
561
562		$s .= "</ul><br/><ul>\n";
563		$s .= $this->getPageListItem( 'Restart', true, $currentPageName );
564		// End list pane
565		$s .= "</ul></div>\n";
566
567		// Messages:
568		// config-page-language, config-page-welcome, config-page-dbconnect, config-page-upgrade,
569		// config-page-dbsettings, config-page-name, config-page-options, config-page-install,
570		// config-page-complete, config-page-restart, config-page-releasenotes,
571		// config-page-copying, config-page-upgradedoc, config-page-existingwiki
572		$s .= Html::element( 'h2', [],
573			wfMessage( 'config-page-' . strtolower( $currentPageName ) )->text() );
574
575		$this->output->addHTMLNoFlush( $s );
576	}
577
578	/**
579	 * Get a list item for the page list.
580	 *
581	 * @param string $pageName
582	 * @param bool $enabled
583	 * @param string $currentPageName
584	 *
585	 * @return string
586	 */
587	private function getPageListItem( $pageName, $enabled, $currentPageName ) {
588		$s = "<li class=\"config-page-list-item\">";
589
590		// Messages:
591		// config-page-language, config-page-welcome, config-page-dbconnect, config-page-upgrade,
592		// config-page-dbsettings, config-page-name, config-page-options, config-page-install,
593		// config-page-complete, config-page-restart, config-page-releasenotes,
594		// config-page-copying, config-page-upgradedoc, config-page-existingwiki
595		$name = wfMessage( 'config-page-' . strtolower( $pageName ) )->text();
596
597		if ( $enabled ) {
598			$query = [ 'page' => $pageName ];
599
600			if ( !in_array( $pageName, $this->pageSequence ) ) {
601				if ( in_array( $currentPageName, $this->pageSequence ) ) {
602					$query['lastPage'] = $currentPageName;
603				}
604
605				$link = Html::element( 'a',
606					[
607						'href' => $this->getUrl( $query )
608					],
609					$name
610				);
611			} else {
612				$link = htmlspecialchars( $name );
613			}
614
615			if ( $pageName == $currentPageName ) {
616				$s .= "<span class=\"config-page-current\">$link</span>";
617			} else {
618				$s .= $link;
619			}
620		} else {
621			$s .= Html::element( 'span',
622				[
623					'class' => 'config-page-disabled'
624				],
625				$name
626			);
627		}
628
629		$s .= "</li>\n";
630
631		return $s;
632	}
633
634	/**
635	 * Output some stuff after a page is finished.
636	 */
637	private function endPageWrapper() {
638		$this->output->addHTMLNoFlush(
639			"<div class=\"visualClear\"></div>\n" .
640			"</div>\n" .
641			"<div class=\"visualClear\"></div>\n" .
642			"</div>" );
643	}
644
645	/**
646	 * Get HTML for an information message box with an icon.
647	 *
648	 * @param string|HtmlArmor $text Wikitext to be parsed (from Message::plain) or raw HTML.
649	 * @param string|bool $icon Icon name, file in mw-config/images. Default: false
650	 * @param string|bool $class Additional class name to add to the wrapper div. Default: false.
651	 * @return string HTML
652	 */
653	public function getInfoBox( $text, $icon = false, $class = false ) {
654		$html = ( $text instanceof HtmlArmor ) ?
655			HtmlArmor::getHtml( $text ) :
656			$this->parse( $text, true );
657		$icon = ( !$icon ) ?
658			'images/info-32.png' :
659			'images/' . $icon;
660		$alt = wfMessage( 'config-information' )->text();
661
662		return self::infoBox( $html, $icon, $alt, $class );
663	}
664
665	/**
666	 * Get small text indented help for a preceding form field.
667	 * Parameters like wfMessage().
668	 *
669	 * @param string $msg
670	 * @param mixed ...$args
671	 * @return string HTML
672	 * @return-taint escaped
673	 */
674	public function getHelpBox( $msg, ...$args ) {
675		$args = array_map( 'htmlspecialchars', $args );
676		$text = wfMessage( $msg, $args )->useDatabase( false )->plain();
677		$html = $this->parse( $text, true );
678		$id = 'helpBox-' . $this->helpBoxId++;
679
680		return "<div class=\"config-help-field-container\">\n" .
681			"<input type=\"checkbox\" class=\"config-help-field-checkbox\" id=\"$id\" />" .
682			"<label class=\"config-help-field-hint\" for=\"$id\" title=\"" .
683			wfMessage( 'config-help-tooltip' )->escaped() . "\">" .
684			wfMessage( 'config-help' )->escaped() . "</label>\n" .
685			"<div class=\"config-help-field-data\">" . $html . "</div>\n" .
686			"</div>\n";
687	}
688
689	/**
690	 * Output a help box.
691	 * @param string $msg Key for wfMessage()
692	 * @param mixed ...$params
693	 */
694	public function showHelpBox( $msg, ...$params ) {
695		$html = $this->getHelpBox( $msg, ...$params );
696		$this->output->addHTML( $html );
697	}
698
699	/**
700	 * Show a short informational message.
701	 * Output looks like a list.
702	 *
703	 * @param string $msg
704	 * @param mixed ...$params
705	 */
706	public function showMessage( $msg, ...$params ) {
707		$html = '<div class="config-message">' .
708			$this->parse( wfMessage( $msg, $params )->useDatabase( false )->plain() ) .
709			"</div>\n";
710		$this->output->addHTML( $html );
711	}
712
713	/**
714	 * @param Status $status
715	 */
716	public function showStatusMessage( Status $status ) {
717		$errors = array_merge( $status->getErrorsArray(), $status->getWarningsArray() );
718		foreach ( $errors as $error ) {
719			$this->showMessage( ...$error );
720		}
721	}
722
723	/**
724	 * Label a control by wrapping a config-input div around it and putting a
725	 * label before it.
726	 *
727	 * @param string $msg
728	 * @param string $forId
729	 * @param string $contents HTML
730	 * @param string $helpData
731	 * @return string HTML
732	 * @return-taint escaped
733	 */
734	public function label( $msg, $forId, $contents, $helpData = "" ) {
735		if ( strval( $msg ) == '' ) {
736			$labelText = "\u{00A0}";
737		} else {
738			$labelText = wfMessage( $msg )->escaped();
739		}
740
741		$attributes = [ 'class' => 'config-label' ];
742
743		if ( $forId ) {
744			$attributes['for'] = $forId;
745		}
746
747		return "<div class=\"config-block\">\n" .
748			"  <div class=\"config-block-label\">\n" .
749			Xml::tags( 'label',
750				$attributes,
751				$labelText
752			) . "\n" .
753			$helpData .
754			"  </div>\n" .
755			"  <div class=\"config-block-elements\">\n" .
756			$contents .
757			"  </div>\n" .
758			"</div>\n";
759	}
760
761	/**
762	 * Get a labelled text box to configure a variable.
763	 *
764	 * @param mixed[] $params
765	 *    Parameters are:
766	 *      var:         The variable to be configured (required)
767	 *      label:       The message name for the label (required)
768	 *      attribs:     Additional attributes for the input element (optional)
769	 *      controlName: The name for the input element (optional)
770	 *      value:       The current value of the variable (optional)
771	 *      help:        The html for the help text (optional)
772	 *
773	 * @return string HTML
774	 * @return-taint escaped
775	 */
776	public function getTextBox( $params ) {
777		if ( !isset( $params['controlName'] ) ) {
778			$params['controlName'] = 'config_' . $params['var'];
779		}
780
781		if ( !isset( $params['value'] ) ) {
782			$params['value'] = $this->getVar( $params['var'] );
783		}
784
785		if ( !isset( $params['attribs'] ) ) {
786			$params['attribs'] = [];
787		}
788		if ( !isset( $params['help'] ) ) {
789			$params['help'] = "";
790		}
791
792		return $this->label(
793			$params['label'],
794			$params['controlName'],
795			Xml::input(
796				$params['controlName'],
797				30, // intended to be overridden by CSS
798				$params['value'],
799				$params['attribs'] + [
800					'id' => $params['controlName'],
801					'class' => 'config-input-text',
802					'tabindex' => $this->nextTabIndex()
803				]
804			),
805			$params['help']
806		);
807	}
808
809	/**
810	 * Get a labelled textarea to configure a variable
811	 *
812	 * @param mixed[] $params
813	 *    Parameters are:
814	 *      var:         The variable to be configured (required)
815	 *      label:       The message name for the label (required)
816	 *      attribs:     Additional attributes for the input element (optional)
817	 *      controlName: The name for the input element (optional)
818	 *      value:       The current value of the variable (optional)
819	 *      help:        The html for the help text (optional)
820	 *
821	 * @return string
822	 */
823	public function getTextArea( $params ) {
824		if ( !isset( $params['controlName'] ) ) {
825			$params['controlName'] = 'config_' . $params['var'];
826		}
827
828		if ( !isset( $params['value'] ) ) {
829			$params['value'] = $this->getVar( $params['var'] );
830		}
831
832		if ( !isset( $params['attribs'] ) ) {
833			$params['attribs'] = [];
834		}
835		if ( !isset( $params['help'] ) ) {
836			$params['help'] = "";
837		}
838
839		return $this->label(
840			$params['label'],
841			$params['controlName'],
842			Xml::textarea(
843				$params['controlName'],
844				$params['value'],
845				30,
846				5,
847				$params['attribs'] + [
848					'id' => $params['controlName'],
849					'class' => 'config-input-text',
850					'tabindex' => $this->nextTabIndex()
851				]
852			),
853			$params['help']
854		);
855	}
856
857	/**
858	 * Get a labelled password box to configure a variable.
859	 *
860	 * Implements password hiding
861	 * @param mixed[] $params
862	 *    Parameters are:
863	 *      var:         The variable to be configured (required)
864	 *      label:       The message name for the label (required)
865	 *      attribs:     Additional attributes for the input element (optional)
866	 *      controlName: The name for the input element (optional)
867	 *      value:       The current value of the variable (optional)
868	 *      help:        The html for the help text (optional)
869	 *
870	 * @return string HTML
871	 * @return-taint escaped
872	 */
873	public function getPasswordBox( $params ) {
874		if ( !isset( $params['value'] ) ) {
875			$params['value'] = $this->getVar( $params['var'] );
876		}
877
878		if ( !isset( $params['attribs'] ) ) {
879			$params['attribs'] = [];
880		}
881
882		$params['value'] = $this->getFakePassword( $params['value'] );
883		$params['attribs']['type'] = 'password';
884
885		return $this->getTextBox( $params );
886	}
887
888	/**
889	 * Get a labelled checkbox to configure a boolean variable.
890	 *
891	 * @param mixed[] $params
892	 *    Parameters are:
893	 *      var:         The variable to be configured (required)
894	 *      label:       The message name for the label (required)
895	 *      labelAttribs:Additional attributes for the label element (optional)
896	 *      attribs:     Additional attributes for the input element (optional)
897	 *      controlName: The name for the input element (optional)
898	 *      value:       The current value of the variable (optional)
899	 *      help:        The html for the help text (optional)
900	 *
901	 * @return string HTML
902	 * @return-taint escaped
903	 */
904	public function getCheckBox( $params ) {
905		if ( !isset( $params['controlName'] ) ) {
906			$params['controlName'] = 'config_' . $params['var'];
907		}
908
909		if ( !isset( $params['value'] ) ) {
910			$params['value'] = $this->getVar( $params['var'] );
911		}
912
913		if ( !isset( $params['attribs'] ) ) {
914			$params['attribs'] = [];
915		}
916		if ( !isset( $params['help'] ) ) {
917			$params['help'] = "";
918		}
919		if ( !isset( $params['labelAttribs'] ) ) {
920			$params['labelAttribs'] = [];
921		}
922		$labelText = $params['rawtext'] ?? $this->parse( wfMessage( $params['label'] )->plain() );
923
924		return "<div class=\"config-input-check\">\n" .
925			$params['help'] .
926			Html::rawElement(
927				'label',
928				$params['labelAttribs'],
929				Xml::check(
930					$params['controlName'],
931					$params['value'],
932					$params['attribs'] + [
933						'id' => $params['controlName'],
934						'tabindex' => $this->nextTabIndex(),
935					]
936				) .
937				$labelText . "\n"
938				) .
939			"</div>\n";
940	}
941
942	/**
943	 * Get a set of labelled radio buttons.
944	 *
945	 * @param mixed[] $params
946	 *    Parameters are:
947	 *      var:             The variable to be configured (required)
948	 *      label:           The message name for the label (required)
949	 *      itemLabelPrefix: The message name prefix for the item labels (required)
950	 *      itemLabels:      List of message names to use for the item labels instead
951	 *                       of itemLabelPrefix, keyed by values
952	 *      values:          List of allowed values (required)
953	 *      itemAttribs:     Array of attribute arrays, outer key is the value name (optional)
954	 *      commonAttribs:   Attribute array applied to all items
955	 *      controlName:     The name for the input element (optional)
956	 *      value:           The current value of the variable (optional)
957	 *      help:            The html for the help text (optional)
958	 *
959	 * @return string HTML
960	 * @return-taint escaped
961	 */
962	public function getRadioSet( $params ) {
963		$items = $this->getRadioElements( $params );
964
965		$label = $params['label'] ?? '';
966
967		if ( !isset( $params['controlName'] ) ) {
968			$params['controlName'] = 'config_' . $params['var'];
969		}
970
971		if ( !isset( $params['help'] ) ) {
972			$params['help'] = "";
973		}
974
975		$s = "<ul>\n";
976		foreach ( $items as $value => $item ) {
977			$s .= "<li>$item</li>\n";
978		}
979		$s .= "</ul>\n";
980
981		return $this->label( $label, $params['controlName'], $s, $params['help'] );
982	}
983
984	/**
985	 * Get a set of labelled radio buttons. You probably want to use getRadioSet(), not this.
986	 *
987	 * @see getRadioSet
988	 *
989	 * @param mixed[] $params
990	 * @return string[] HTML
991	 * @return-taint escaped
992	 */
993	public function getRadioElements( $params ) {
994		if ( !isset( $params['controlName'] ) ) {
995			$params['controlName'] = 'config_' . $params['var'];
996		}
997
998		if ( !isset( $params['value'] ) ) {
999			$params['value'] = $this->getVar( $params['var'] );
1000		}
1001
1002		$items = [];
1003
1004		foreach ( $params['values'] as $value ) {
1005			$itemAttribs = [];
1006
1007			if ( isset( $params['commonAttribs'] ) ) {
1008				$itemAttribs = $params['commonAttribs'];
1009			}
1010
1011			if ( isset( $params['itemAttribs'][$value] ) ) {
1012				$itemAttribs = $params['itemAttribs'][$value] + $itemAttribs;
1013			}
1014
1015			$checked = $value == $params['value'];
1016			$id = $params['controlName'] . '_' . $value;
1017			$itemAttribs['id'] = $id;
1018			$itemAttribs['tabindex'] = $this->nextTabIndex();
1019
1020			$items[$value] =
1021				Xml::radio( $params['controlName'], $value, $checked, $itemAttribs ) .
1022				"\u{00A0}" .
1023				Xml::tags( 'label', [ 'for' => $id ], $this->parse(
1024					isset( $params['itemLabels'] ) ?
1025						wfMessage( $params['itemLabels'][$value] )->plain() :
1026						wfMessage( $params['itemLabelPrefix'] . strtolower( $value ) )->plain()
1027				) );
1028		}
1029
1030		return $items;
1031	}
1032
1033	/**
1034	 * Output an error or warning box using a Status object.
1035	 *
1036	 * @param Status $status
1037	 */
1038	public function showStatusBox( $status ) {
1039		if ( !$status->isGood() ) {
1040			$html = $status->getHTML();
1041
1042			if ( $status->isOK() ) {
1043				$box = Html::warningBox( $html, 'config-warning-box' );
1044			} else {
1045				$box = Html::errorBox( $html, '', 'config-error-box' );
1046			}
1047
1048			$this->output->addHTML( $box );
1049		}
1050	}
1051
1052	/**
1053	 * Convenience function to set variables based on form data.
1054	 * Assumes that variables containing "password" in the name are (potentially
1055	 * fake) passwords.
1056	 *
1057	 * @param string[] $varNames
1058	 * @param string $prefix The prefix added to variables to obtain form names
1059	 *
1060	 * @return string[]
1061	 */
1062	public function setVarsFromRequest( $varNames, $prefix = 'config_' ) {
1063		$newValues = [];
1064
1065		foreach ( $varNames as $name ) {
1066			$value = $this->request->getVal( $prefix . $name );
1067			// T32524, do not trim passwords
1068			if ( stripos( $name, 'password' ) === false ) {
1069				$value = trim( $value );
1070			}
1071			$newValues[$name] = $value;
1072
1073			if ( $value === null ) {
1074				// Checkbox?
1075				$this->setVar( $name, false );
1076			} elseif ( stripos( $name, 'password' ) !== false ) {
1077				$this->setPassword( $name, $value );
1078			} else {
1079				$this->setVar( $name, $value );
1080			}
1081		}
1082
1083		return $newValues;
1084	}
1085
1086	/**
1087	 * Helper for WebInstallerOutput
1088	 *
1089	 * @internal For use by WebInstallerOutput
1090	 * @param string $page
1091	 * @return string
1092	 */
1093	public function getDocUrl( $page ) {
1094		$query = [ 'page' => $page ];
1095
1096		if ( in_array( $this->currentPageName, $this->pageSequence ) ) {
1097			$query['lastPage'] = $this->currentPageName;
1098		}
1099
1100		return $this->getUrl( $query );
1101	}
1102
1103	/**
1104	 * Helper for sidebar links.
1105	 *
1106	 * @internal For use in WebInstallerOutput class
1107	 * @param string $url
1108	 * @param string $linkText
1109	 * @return string HTML
1110	 */
1111	public function makeLinkItem( $url, $linkText ) {
1112		return Html::rawElement( 'li', [],
1113			Html::element( 'a', [ 'href' => $url ], $linkText )
1114		);
1115	}
1116
1117	/**
1118	 * Helper for "Download LocalSettings" link.
1119	 *
1120	 * @internal For use in WebInstallerComplete class
1121	 * @return string Html for download link
1122	 */
1123	public function makeDownloadLinkHtml() {
1124		$anchor = Html::rawElement( 'a',
1125			[ 'href' => $this->getUrl( [ 'localsettings' => 1 ] ) ],
1126			wfMessage( 'config-download-localsettings' )->parse()
1127		);
1128
1129		return Html::rawElement( 'div', [ 'class' => 'config-download-link' ], $anchor );
1130	}
1131
1132	/**
1133	 * If the software package wants the LocalSettings.php file
1134	 * to be placed in a specific location, override this function
1135	 * (see mw-config/overrides/README) to return the path of
1136	 * where the file should be saved, or false for a generic
1137	 * "in the base of your install"
1138	 *
1139	 * @since 1.27
1140	 * @return string|bool
1141	 */
1142	public function getLocalSettingsLocation() {
1143		return false;
1144	}
1145
1146	/**
1147	 * @return bool
1148	 */
1149	public function envCheckPath() {
1150		// PHP_SELF isn't available sometimes, such as when PHP is CGI but
1151		// cgi.fix_pathinfo is disabled. In that case, fall back to SCRIPT_NAME
1152		// to get the path to the current script... hopefully it's reliable. SIGH
1153		$path = false;
1154		if ( !empty( $_SERVER['PHP_SELF'] ) ) {
1155			$path = $_SERVER['PHP_SELF'];
1156		} elseif ( !empty( $_SERVER['SCRIPT_NAME'] ) ) {
1157			$path = $_SERVER['SCRIPT_NAME'];
1158		}
1159		if ( $path === false ) {
1160			$this->showError( 'config-no-uri' );
1161			return false;
1162		}
1163
1164		return parent::envCheckPath();
1165	}
1166
1167	public function envPrepPath() {
1168		parent::envPrepPath();
1169		// PHP_SELF isn't available sometimes, such as when PHP is CGI but
1170		// cgi.fix_pathinfo is disabled. In that case, fall back to SCRIPT_NAME
1171		// to get the path to the current script... hopefully it's reliable. SIGH
1172		$path = false;
1173		if ( !empty( $_SERVER['PHP_SELF'] ) ) {
1174			$path = $_SERVER['PHP_SELF'];
1175		} elseif ( !empty( $_SERVER['SCRIPT_NAME'] ) ) {
1176			$path = $_SERVER['SCRIPT_NAME'];
1177		}
1178		if ( $path !== false ) {
1179			$scriptPath = preg_replace( '{^(.*)/(mw-)?config.*$}', '$1', $path );
1180
1181			$this->setVar( 'wgScriptPath', "$scriptPath" );
1182			// Update variables set from Setup.php that are derived from wgScriptPath
1183			$this->setVar( 'wgScript', "$scriptPath/index.php" );
1184			$this->setVar( 'wgLoadScript', "$scriptPath/load.php" );
1185			$this->setVar( 'wgStylePath', "$scriptPath/skins" );
1186			$this->setVar( 'wgLocalStylePath', "$scriptPath/skins" );
1187			$this->setVar( 'wgExtensionAssetsPath', "$scriptPath/extensions" );
1188			$this->setVar( 'wgUploadPath', "$scriptPath/images" );
1189			$this->setVar( 'wgResourceBasePath', "$scriptPath" );
1190		}
1191	}
1192
1193	/**
1194	 * @return string
1195	 */
1196	protected function envGetDefaultServer() {
1197		return WebRequest::detectServer();
1198	}
1199
1200	/**
1201	 * Actually output LocalSettings.php for download
1202	 */
1203	private function outputLS() {
1204		$this->request->response()->header( 'Content-type: application/x-httpd-php' );
1205		$this->request->response()->header(
1206			'Content-Disposition: attachment; filename="LocalSettings.php"'
1207		);
1208
1209		$ls = InstallerOverrides::getLocalSettingsGenerator( $this );
1210		$rightsProfile = $this->rightsProfiles[$this->getVar( '_RightsProfile' )];
1211		foreach ( $rightsProfile as $group => $rightsArr ) {
1212			$ls->setGroupRights( $group, $rightsArr );
1213		}
1214		echo $ls->getText();
1215	}
1216
1217	/**
1218	 * Output stylesheet for web installer pages
1219	 */
1220	public function outputCss() {
1221		$this->request->response()->header( 'Content-type: text/css' );
1222		echo $this->output->getCSS();
1223	}
1224
1225	/**
1226	 * @return string[]
1227	 */
1228	public function getPhpErrors() {
1229		return $this->phpErrors;
1230	}
1231
1232	/**
1233	 * Get HTML for an information message box with an icon.
1234	 *
1235	 * @since 1.36
1236	 * @param string $rawHtml HTML
1237	 * @param string $icon Path to icon file (used as 'src' attribute)
1238	 * @param string $alt Alternate text for the icon
1239	 * @param string $class Additional class name to add to the wrapper div
1240	 * @return string HTML
1241	 */
1242	protected static function infoBox( $rawHtml, $icon, $alt, $class = '' ) {
1243		$s = Html::openElement( 'div', [ 'class' => 'mw-installer-box-left' ] ) .
1244				Html::element( 'img',
1245					[
1246						'src' => $icon,
1247						'alt' => $alt,
1248					]
1249				) .
1250				Html::closeElement( 'div' );
1251
1252		$s .= Html::openElement( 'div', [ 'class' => 'mw-installer-box-right' ] ) .
1253				$rawHtml .
1254				Html::closeElement( 'div' );
1255		$s .= Html::element( 'div', [ 'style' => 'clear: left;' ], ' ' );
1256
1257		return Html::warningBox( $s, $class )
1258			. Html::element( 'div', [ 'style' => 'clear: left;' ], ' ' );
1259	}
1260
1261}
1262