Merge "Convert various FormActions to OOUI"
[lhc/web/wiklou.git] / includes / installer / PostgresInstaller.php
1 <?php
2 /**
3 * PostgreSQL-specific installer.
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 Deployment
22 */
23
24 use Wikimedia\Rdbms\Database;
25
26 /**
27 * Class for setting up the MediaWiki database using Postgres.
28 *
29 * @ingroup Deployment
30 * @since 1.17
31 */
32 class PostgresInstaller extends DatabaseInstaller {
33
34 protected $globalNames = [
35 'wgDBserver',
36 'wgDBport',
37 'wgDBname',
38 'wgDBuser',
39 'wgDBpassword',
40 'wgDBmwschema',
41 ];
42
43 protected $internalDefaults = [
44 '_InstallUser' => 'postgres',
45 ];
46
47 public $minimumVersion = '8.3';
48 public $maxRoleSearchDepth = 5;
49
50 protected $pgConns = [];
51
52 function getName() {
53 return 'postgres';
54 }
55
56 public function isCompiled() {
57 return self::checkExtension( 'pgsql' );
58 }
59
60 function getConnectForm() {
61 return $this->getTextBox(
62 'wgDBserver',
63 'config-db-host',
64 [],
65 $this->parent->getHelpBox( 'config-db-host-help' )
66 ) .
67 $this->getTextBox( 'wgDBport', 'config-db-port' ) .
68 Html::openElement( 'fieldset' ) .
69 Html::element( 'legend', [], wfMessage( 'config-db-wiki-settings' )->text() ) .
70 $this->getTextBox(
71 'wgDBname',
72 'config-db-name',
73 [],
74 $this->parent->getHelpBox( 'config-db-name-help' )
75 ) .
76 $this->getTextBox(
77 'wgDBmwschema',
78 'config-db-schema',
79 [],
80 $this->parent->getHelpBox( 'config-db-schema-help' )
81 ) .
82 Html::closeElement( 'fieldset' ) .
83 $this->getInstallUserBox();
84 }
85
86 function submitConnectForm() {
87 // Get variables from the request
88 $newValues = $this->setVarsFromRequest( [
89 'wgDBserver',
90 'wgDBport',
91 'wgDBname',
92 'wgDBmwschema'
93 ] );
94
95 // Validate them
96 $status = Status::newGood();
97 if ( !strlen( $newValues['wgDBname'] ) ) {
98 $status->fatal( 'config-missing-db-name' );
99 } elseif ( !preg_match( '/^[a-zA-Z0-9_]+$/', $newValues['wgDBname'] ) ) {
100 $status->fatal( 'config-invalid-db-name', $newValues['wgDBname'] );
101 }
102 if ( !preg_match( '/^[a-zA-Z0-9_]*$/', $newValues['wgDBmwschema'] ) ) {
103 $status->fatal( 'config-invalid-schema', $newValues['wgDBmwschema'] );
104 }
105
106 // Submit user box
107 if ( $status->isOK() ) {
108 $status->merge( $this->submitInstallUserBox() );
109 }
110 if ( !$status->isOK() ) {
111 return $status;
112 }
113
114 $status = $this->getPgConnection( 'create-db' );
115 if ( !$status->isOK() ) {
116 return $status;
117 }
118 /**
119 * @var $conn Database
120 */
121 $conn = $status->value;
122
123 // Check version
124 $version = $conn->getServerVersion();
125 if ( version_compare( $version, $this->minimumVersion ) < 0 ) {
126 return Status::newFatal( 'config-postgres-old', $this->minimumVersion, $version );
127 }
128
129 $this->setVar( 'wgDBuser', $this->getVar( '_InstallUser' ) );
130 $this->setVar( 'wgDBpassword', $this->getVar( '_InstallPassword' ) );
131
132 return Status::newGood();
133 }
134
135 public function getConnection() {
136 $status = $this->getPgConnection( 'create-tables' );
137 if ( $status->isOK() ) {
138 $this->db = $status->value;
139 }
140
141 return $status;
142 }
143
144 public function openConnection() {
145 return $this->openPgConnection( 'create-tables' );
146 }
147
148 /**
149 * Open a PG connection with given parameters
150 * @param string $user User name
151 * @param string $password Password
152 * @param string $dbName Database name
153 * @param string $schema Database schema
154 * @return Status
155 */
156 protected function openConnectionWithParams( $user, $password, $dbName, $schema ) {
157 $status = Status::newGood();
158 try {
159 $db = Database::factory( 'postgres', [
160 'host' => $this->getVar( 'wgDBserver' ),
161 'port' => $this->getVar( 'wgDBport' ),
162 'user' => $user,
163 'password' => $password,
164 'dbname' => $dbName,
165 'schema' => $schema,
166 'keywordTableMap' => [ 'user' => 'mwuser', 'text' => 'pagecontent' ],
167 ] );
168 $status->value = $db;
169 } catch ( DBConnectionError $e ) {
170 $status->fatal( 'config-connection-error', $e->getMessage() );
171 }
172
173 return $status;
174 }
175
176 /**
177 * Get a special type of connection
178 * @param string $type See openPgConnection() for details.
179 * @return Status
180 */
181 protected function getPgConnection( $type ) {
182 if ( isset( $this->pgConns[$type] ) ) {
183 return Status::newGood( $this->pgConns[$type] );
184 }
185 $status = $this->openPgConnection( $type );
186
187 if ( $status->isOK() ) {
188 /**
189 * @var $conn Database
190 */
191 $conn = $status->value;
192 $conn->clearFlag( DBO_TRX );
193 $conn->commit( __METHOD__ );
194 $this->pgConns[$type] = $conn;
195 }
196
197 return $status;
198 }
199
200 /**
201 * Get a connection of a specific PostgreSQL-specific type. Connections
202 * of a given type are cached.
203 *
204 * PostgreSQL lacks cross-database operations, so after the new database is
205 * created, you need to make a separate connection to connect to that
206 * database and add tables to it.
207 *
208 * New tables are owned by the user that creates them, and MediaWiki's
209 * PostgreSQL support has always assumed that the table owner will be
210 * $wgDBuser. So before we create new tables, we either need to either
211 * connect as the other user or to execute a SET ROLE command. Using a
212 * separate connection for this allows us to avoid accidental cross-module
213 * dependencies.
214 *
215 * @param string $type The type of connection to get:
216 * - create-db: A connection for creating DBs, suitable for pre-
217 * installation.
218 * - create-schema: A connection to the new DB, for creating schemas and
219 * other similar objects in the new DB.
220 * - create-tables: A connection with a role suitable for creating tables.
221 *
222 * @throws MWException
223 * @return Status On success, a connection object will be in the value member.
224 */
225 protected function openPgConnection( $type ) {
226 switch ( $type ) {
227 case 'create-db':
228 return $this->openConnectionToAnyDB(
229 $this->getVar( '_InstallUser' ),
230 $this->getVar( '_InstallPassword' ) );
231 case 'create-schema':
232 return $this->openConnectionWithParams(
233 $this->getVar( '_InstallUser' ),
234 $this->getVar( '_InstallPassword' ),
235 $this->getVar( 'wgDBname' ),
236 $this->getVar( 'wgDBmwschema' ) );
237 case 'create-tables':
238 $status = $this->openPgConnection( 'create-schema' );
239 if ( $status->isOK() ) {
240 /**
241 * @var $conn Database
242 */
243 $conn = $status->value;
244 $safeRole = $conn->addIdentifierQuotes( $this->getVar( 'wgDBuser' ) );
245 $conn->query( "SET ROLE $safeRole" );
246 }
247
248 return $status;
249 default:
250 throw new MWException( "Invalid special connection type: \"$type\"" );
251 }
252 }
253
254 public function openConnectionToAnyDB( $user, $password ) {
255 $dbs = [
256 'template1',
257 'postgres',
258 ];
259 if ( !in_array( $this->getVar( 'wgDBname' ), $dbs ) ) {
260 array_unshift( $dbs, $this->getVar( 'wgDBname' ) );
261 }
262 $conn = false;
263 $status = Status::newGood();
264 foreach ( $dbs as $db ) {
265 try {
266 $p = [
267 'host' => $this->getVar( 'wgDBserver' ),
268 'user' => $user,
269 'password' => $password,
270 'dbname' => $db
271 ];
272 $conn = Database::factory( 'postgres', $p );
273 } catch ( DBConnectionError $error ) {
274 $conn = false;
275 $status->fatal( 'config-pg-test-error', $db,
276 $error->getMessage() );
277 }
278 if ( $conn !== false ) {
279 break;
280 }
281 }
282 if ( $conn !== false ) {
283 return Status::newGood( $conn );
284 } else {
285 return $status;
286 }
287 }
288
289 protected function getInstallUserPermissions() {
290 $status = $this->getPgConnection( 'create-db' );
291 if ( !$status->isOK() ) {
292 return false;
293 }
294 /**
295 * @var $conn Database
296 */
297 $conn = $status->value;
298 $superuser = $this->getVar( '_InstallUser' );
299
300 $row = $conn->selectRow( '"pg_catalog"."pg_roles"', '*',
301 [ 'rolname' => $superuser ], __METHOD__ );
302
303 return $row;
304 }
305
306 protected function canCreateAccounts() {
307 $perms = $this->getInstallUserPermissions();
308 if ( !$perms ) {
309 return false;
310 }
311
312 return $perms->rolsuper === 't' || $perms->rolcreaterole === 't';
313 }
314
315 protected function isSuperUser() {
316 $perms = $this->getInstallUserPermissions();
317 if ( !$perms ) {
318 return false;
319 }
320
321 return $perms->rolsuper === 't';
322 }
323
324 public function getSettingsForm() {
325 if ( $this->canCreateAccounts() ) {
326 $noCreateMsg = false;
327 } else {
328 $noCreateMsg = 'config-db-web-no-create-privs';
329 }
330 $s = $this->getWebUserBox( $noCreateMsg );
331
332 return $s;
333 }
334
335 public function submitSettingsForm() {
336 $status = $this->submitWebUserBox();
337 if ( !$status->isOK() ) {
338 return $status;
339 }
340
341 $same = $this->getVar( 'wgDBuser' ) === $this->getVar( '_InstallUser' );
342
343 if ( $same ) {
344 $exists = true;
345 } else {
346 // Check if the web user exists
347 // Connect to the database with the install user
348 $status = $this->getPgConnection( 'create-db' );
349 if ( !$status->isOK() ) {
350 return $status;
351 }
352 $exists = $status->value->roleExists( $this->getVar( 'wgDBuser' ) );
353 }
354
355 // Validate the create checkbox
356 if ( $this->canCreateAccounts() && !$same && !$exists ) {
357 $create = $this->getVar( '_CreateDBAccount' );
358 } else {
359 $this->setVar( '_CreateDBAccount', false );
360 $create = false;
361 }
362
363 if ( !$create && !$exists ) {
364 if ( $this->canCreateAccounts() ) {
365 $msg = 'config-install-user-missing-create';
366 } else {
367 $msg = 'config-install-user-missing';
368 }
369
370 return Status::newFatal( $msg, $this->getVar( 'wgDBuser' ) );
371 }
372
373 if ( !$exists ) {
374 // No more checks to do
375 return Status::newGood();
376 }
377
378 // Existing web account. Test the connection.
379 $status = $this->openConnectionToAnyDB(
380 $this->getVar( 'wgDBuser' ),
381 $this->getVar( 'wgDBpassword' ) );
382 if ( !$status->isOK() ) {
383 return $status;
384 }
385
386 // The web user is conventionally the table owner in PostgreSQL
387 // installations. Make sure the install user is able to create
388 // objects on behalf of the web user.
389 if ( $same || $this->canCreateObjectsForWebUser() ) {
390 return Status::newGood();
391 } else {
392 return Status::newFatal( 'config-pg-not-in-role' );
393 }
394 }
395
396 /**
397 * Returns true if the install user is able to create objects owned
398 * by the web user, false otherwise.
399 * @return bool
400 */
401 protected function canCreateObjectsForWebUser() {
402 if ( $this->isSuperUser() ) {
403 return true;
404 }
405
406 $status = $this->getPgConnection( 'create-db' );
407 if ( !$status->isOK() ) {
408 return false;
409 }
410 $conn = $status->value;
411 $installerId = $conn->selectField( '"pg_catalog"."pg_roles"', 'oid',
412 [ 'rolname' => $this->getVar( '_InstallUser' ) ], __METHOD__ );
413 $webId = $conn->selectField( '"pg_catalog"."pg_roles"', 'oid',
414 [ 'rolname' => $this->getVar( 'wgDBuser' ) ], __METHOD__ );
415
416 return $this->isRoleMember( $conn, $installerId, $webId, $this->maxRoleSearchDepth );
417 }
418
419 /**
420 * Recursive helper for canCreateObjectsForWebUser().
421 * @param Database $conn
422 * @param int $targetMember Role ID of the member to look for
423 * @param int $group Role ID of the group to look for
424 * @param int $maxDepth Maximum recursive search depth
425 * @return bool
426 */
427 protected function isRoleMember( $conn, $targetMember, $group, $maxDepth ) {
428 if ( $targetMember === $group ) {
429 // A role is always a member of itself
430 return true;
431 }
432 // Get all members of the given group
433 $res = $conn->select( '"pg_catalog"."pg_auth_members"', [ 'member' ],
434 [ 'roleid' => $group ], __METHOD__ );
435 foreach ( $res as $row ) {
436 if ( $row->member == $targetMember ) {
437 // Found target member
438 return true;
439 }
440 // Recursively search each member of the group to see if the target
441 // is a member of it, up to the given maximum depth.
442 if ( $maxDepth > 0 ) {
443 if ( $this->isRoleMember( $conn, $targetMember, $row->member, $maxDepth - 1 ) ) {
444 // Found member of member
445 return true;
446 }
447 }
448 }
449
450 return false;
451 }
452
453 public function preInstall() {
454 $createDbAccount = [
455 'name' => 'user',
456 'callback' => [ $this, 'setupUser' ],
457 ];
458 $commitCB = [
459 'name' => 'pg-commit',
460 'callback' => [ $this, 'commitChanges' ],
461 ];
462 $plpgCB = [
463 'name' => 'pg-plpgsql',
464 'callback' => [ $this, 'setupPLpgSQL' ],
465 ];
466 $schemaCB = [
467 'name' => 'schema',
468 'callback' => [ $this, 'setupSchema' ]
469 ];
470
471 if ( $this->getVar( '_CreateDBAccount' ) ) {
472 $this->parent->addInstallStep( $createDbAccount, 'database' );
473 }
474 $this->parent->addInstallStep( $commitCB, 'interwiki' );
475 $this->parent->addInstallStep( $plpgCB, 'database' );
476 $this->parent->addInstallStep( $schemaCB, 'database' );
477 }
478
479 function setupDatabase() {
480 $status = $this->getPgConnection( 'create-db' );
481 if ( !$status->isOK() ) {
482 return $status;
483 }
484 $conn = $status->value;
485
486 $dbName = $this->getVar( 'wgDBname' );
487
488 $exists = $conn->selectField( '"pg_catalog"."pg_database"', '1',
489 [ 'datname' => $dbName ], __METHOD__ );
490 if ( !$exists ) {
491 $safedb = $conn->addIdentifierQuotes( $dbName );
492 $conn->query( "CREATE DATABASE $safedb", __METHOD__ );
493 }
494
495 return Status::newGood();
496 }
497
498 function setupSchema() {
499 // Get a connection to the target database
500 $status = $this->getPgConnection( 'create-schema' );
501 if ( !$status->isOK() ) {
502 return $status;
503 }
504 $conn = $status->value;
505
506 // Create the schema if necessary
507 $schema = $this->getVar( 'wgDBmwschema' );
508 $safeschema = $conn->addIdentifierQuotes( $schema );
509 $safeuser = $conn->addIdentifierQuotes( $this->getVar( 'wgDBuser' ) );
510 if ( !$conn->schemaExists( $schema ) ) {
511 try {
512 $conn->query( "CREATE SCHEMA $safeschema AUTHORIZATION $safeuser" );
513 } catch ( DBQueryError $e ) {
514 return Status::newFatal( 'config-install-pg-schema-failed',
515 $this->getVar( '_InstallUser' ), $schema );
516 }
517 }
518
519 // Select the new schema in the current connection
520 $conn->determineCoreSchema( $schema );
521
522 return Status::newGood();
523 }
524
525 function commitChanges() {
526 $this->db->commit( __METHOD__ );
527
528 return Status::newGood();
529 }
530
531 function setupUser() {
532 if ( !$this->getVar( '_CreateDBAccount' ) ) {
533 return Status::newGood();
534 }
535
536 $status = $this->getPgConnection( 'create-db' );
537 if ( !$status->isOK() ) {
538 return $status;
539 }
540 $conn = $status->value;
541
542 $safeuser = $conn->addIdentifierQuotes( $this->getVar( 'wgDBuser' ) );
543 $safepass = $conn->addQuotes( $this->getVar( 'wgDBpassword' ) );
544
545 // Check if the user already exists
546 $userExists = $conn->roleExists( $this->getVar( 'wgDBuser' ) );
547 if ( !$userExists ) {
548 // Create the user
549 try {
550 $sql = "CREATE ROLE $safeuser NOCREATEDB LOGIN PASSWORD $safepass";
551
552 // If the install user is not a superuser, we need to make the install
553 // user a member of the new user's group, so that the install user will
554 // be able to create a schema and other objects on behalf of the new user.
555 if ( !$this->isSuperUser() ) {
556 $sql .= ' ROLE' . $conn->addIdentifierQuotes( $this->getVar( '_InstallUser' ) );
557 }
558
559 $conn->query( $sql, __METHOD__ );
560 } catch ( DBQueryError $e ) {
561 return Status::newFatal( 'config-install-user-create-failed',
562 $this->getVar( 'wgDBuser' ), $e->getMessage() );
563 }
564 }
565
566 return Status::newGood();
567 }
568
569 function getLocalSettings() {
570 $port = $this->getVar( 'wgDBport' );
571 $schema = $this->getVar( 'wgDBmwschema' );
572
573 return "# Postgres specific settings
574 \$wgDBport = \"{$port}\";
575 \$wgDBmwschema = \"{$schema}\";";
576 }
577
578 public function preUpgrade() {
579 global $wgDBuser, $wgDBpassword;
580
581 # Normal user and password are selected after this step, so for now
582 # just copy these two
583 $wgDBuser = $this->getVar( '_InstallUser' );
584 $wgDBpassword = $this->getVar( '_InstallPassword' );
585 }
586
587 public function createTables() {
588 $schema = $this->getVar( 'wgDBmwschema' );
589
590 $status = $this->getConnection();
591 if ( !$status->isOK() ) {
592 return $status;
593 }
594
595 /** @var $conn DatabasePostgres */
596 $conn = $status->value;
597
598 if ( $conn->tableExists( 'archive' ) ) {
599 $status->warning( 'config-install-tables-exist' );
600 $this->enableLB();
601
602 return $status;
603 }
604
605 $conn->begin( __METHOD__ );
606
607 if ( !$conn->schemaExists( $schema ) ) {
608 $status->fatal( 'config-install-pg-schema-not-exist' );
609
610 return $status;
611 }
612 $error = $conn->sourceFile( $this->getSchemaPath( $conn ) );
613 if ( $error !== true ) {
614 $conn->reportQueryError( $error, 0, '', __METHOD__ );
615 $conn->rollback( __METHOD__ );
616 $status->fatal( 'config-install-tables-failed', $error );
617 } else {
618 $conn->commit( __METHOD__ );
619 }
620 // Resume normal operations
621 if ( $status->isOK() ) {
622 $this->enableLB();
623 }
624
625 return $status;
626 }
627
628 public function getGlobalDefaults() {
629 // The default $wgDBmwschema is null, which breaks Postgres and other DBMSes that require
630 // the use of a schema, so we need to set it here
631 return array_merge( parent::getGlobalDefaults(), [
632 'wgDBmwschema' => 'mediawiki',
633 ] );
634 }
635
636 public function setupPLpgSQL() {
637 // Connect as the install user, since it owns the database and so is
638 // the user that needs to run "CREATE LANGAUGE"
639 $status = $this->getPgConnection( 'create-schema' );
640 if ( !$status->isOK() ) {
641 return $status;
642 }
643 /**
644 * @var $conn Database
645 */
646 $conn = $status->value;
647
648 $exists = $conn->selectField( '"pg_catalog"."pg_language"', 1,
649 [ 'lanname' => 'plpgsql' ], __METHOD__ );
650 if ( $exists ) {
651 // Already exists, nothing to do
652 return Status::newGood();
653 }
654
655 // plpgsql is not installed, but if we have a pg_pltemplate table, we
656 // should be able to create it
657 $exists = $conn->selectField(
658 [ '"pg_catalog"."pg_class"', '"pg_catalog"."pg_namespace"' ],
659 1,
660 [
661 'pg_namespace.oid=relnamespace',
662 'nspname' => 'pg_catalog',
663 'relname' => 'pg_pltemplate',
664 ],
665 __METHOD__ );
666 if ( $exists ) {
667 try {
668 $conn->query( 'CREATE LANGUAGE plpgsql' );
669 } catch ( DBQueryError $e ) {
670 return Status::newFatal( 'config-pg-no-plpgsql', $this->getVar( 'wgDBname' ) );
671 }
672 } else {
673 return Status::newFatal( 'config-pg-no-plpgsql', $this->getVar( 'wgDBname' ) );
674 }
675
676 return Status::newGood();
677 }
678 }