Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 244
0.00% covered (danger)
0.00%
0 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
PostgresInstaller
0.00% covered (danger)
0.00%
0 / 244
0.00% covered (danger)
0.00%
0 / 26
5402
0.00% covered (danger)
0.00%
0 / 1
 getName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isCompiled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getConnectForm
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSettingsForm
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getConnection
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 openConnection
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 openConnectionWithParams
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 getPgConnection
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 openPgConnection
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 openConnectionToAnyDB
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
42
 getInstallUserPermissions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 canCreateAccounts
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 isSuperUser
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 canCreateObjectsForWebUser
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 isRoleMember
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 preInstall
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 setupDatabase
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 setupSchema
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 commitChanges
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setupUser
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
42
 getLocalSettings
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 preUpgrade
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 createTables
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
56
 createManualTables
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGlobalDefaults
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 setupPLpgSQL
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
30
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 Installer
22 */
23
24namespace MediaWiki\Installer;
25
26use InvalidArgumentException;
27use MediaWiki\MediaWikiServices;
28use MediaWiki\Status\Status;
29use Wikimedia\Rdbms\Database;
30use Wikimedia\Rdbms\DatabaseFactory;
31use Wikimedia\Rdbms\DatabasePostgres;
32use Wikimedia\Rdbms\DBConnectionError;
33use Wikimedia\Rdbms\DBQueryError;
34
35/**
36 * Class for setting up the MediaWiki database using Postgres.
37 *
38 * @ingroup Installer
39 * @since 1.17
40 */
41class PostgresInstaller extends DatabaseInstaller {
42
43    protected $globalNames = [
44        'wgDBserver',
45        'wgDBport',
46        'wgDBname',
47        'wgDBuser',
48        'wgDBpassword',
49        'wgDBssl',
50        'wgDBmwschema',
51    ];
52
53    protected $internalDefaults = [
54        '_InstallUser' => 'postgres',
55    ];
56
57    public static $minimumVersion = '10';
58    protected static $notMinimumVersionMessage = 'config-postgres-old';
59    public $maxRoleSearchDepth = 5;
60
61    /**
62     * @var DatabasePostgres[]
63     */
64    protected $pgConns = [];
65
66    public function getName() {
67        return 'postgres';
68    }
69
70    public function isCompiled() {
71        return self::checkExtension( 'pgsql' );
72    }
73
74    public function getConnectForm( WebInstaller $webInstaller ): DatabaseConnectForm {
75        return new PostgresConnectForm( $webInstaller, $this );
76    }
77
78    public function getSettingsForm( WebInstaller $webInstaller ): DatabaseSettingsForm {
79        return new PostgresSettingsForm( $webInstaller, $this );
80    }
81
82    public function getConnection() {
83        $status = $this->getPgConnection( 'create-tables' );
84        if ( $status->isOK() ) {
85            $this->db = $status->value;
86        }
87
88        return $status;
89    }
90
91    public function openConnection() {
92        return $this->openPgConnection( 'create-tables' );
93    }
94
95    /**
96     * Open a PG connection with given parameters
97     * @param string $user User name
98     * @param string $password
99     * @param string $dbName Database name
100     * @param string $schema Database schema
101     * @return ConnectionStatus
102     */
103    protected function openConnectionWithParams( $user, $password, $dbName, $schema ) {
104        $status = new ConnectionStatus;
105        try {
106            $db = MediaWikiServices::getInstance()->getDatabaseFactory()->create( 'postgres', [
107                'host' => $this->getVar( 'wgDBserver' ),
108                'port' => $this->getVar( 'wgDBport' ),
109                'user' => $user,
110                'password' => $password,
111                'ssl' => $this->getVar( 'wgDBssl' ),
112                'dbname' => $dbName,
113                'schema' => $schema,
114            ] );
115            $status->setDB( $db );
116        } catch ( DBConnectionError $e ) {
117            $status->fatal( 'config-connection-error', $e->getMessage() );
118        }
119
120        return $status;
121    }
122
123    /**
124     * Get a special type of connection
125     * @param string $type See openPgConnection() for details.
126     * @return ConnectionStatus
127     */
128    public function getPgConnection( $type ) {
129        if ( isset( $this->pgConns[$type] ) ) {
130            return new ConnectionStatus( $this->pgConns[$type] );
131        }
132        $status = $this->openPgConnection( $type );
133
134        if ( $status->isOK() ) {
135            $conn = $status->getDB();
136            $conn->clearFlag( DBO_TRX );
137            $conn->commit( __METHOD__ );
138            $this->pgConns[$type] = $conn;
139        }
140
141        return $status;
142    }
143
144    /**
145     * Get a connection of a specific PostgreSQL-specific type. Connections
146     * of a given type are cached.
147     *
148     * PostgreSQL lacks cross-database operations, so after the new database is
149     * created, you need to make a separate connection to connect to that
150     * database and add tables to it.
151     *
152     * New tables are owned by the user that creates them, and MediaWiki's
153     * PostgreSQL support has always assumed that the table owner will be
154     * $wgDBuser. So before we create new tables, we either need to either
155     * connect as the other user or to execute a SET ROLE command. Using a
156     * separate connection for this allows us to avoid accidental cross-module
157     * dependencies.
158     *
159     * @param string $type The type of connection to get:
160     *    - create-db:     A connection for creating DBs, suitable for pre-
161     *                     installation.
162     *    - create-schema: A connection to the new DB, for creating schemas and
163     *                     other similar objects in the new DB.
164     *    - create-tables: A connection with a role suitable for creating tables.
165     * @return ConnectionStatus On success, a connection object will be in the value member.
166     */
167    protected function openPgConnection( $type ) {
168        switch ( $type ) {
169            case 'create-db':
170                return $this->openConnectionToAnyDB(
171                    $this->getVar( '_InstallUser' ),
172                    $this->getVar( '_InstallPassword' ) );
173            case 'create-schema':
174                return $this->openConnectionWithParams(
175                    $this->getVar( '_InstallUser' ),
176                    $this->getVar( '_InstallPassword' ),
177                    $this->getVar( 'wgDBname' ),
178                    $this->getVar( 'wgDBmwschema' ) );
179            case 'create-tables':
180                $status = $this->openPgConnection( 'create-schema' );
181                if ( $status->isOK() ) {
182                    $conn = $status->getDB();
183                    $safeRole = $conn->addIdentifierQuotes( $this->getVar( 'wgDBuser' ) );
184                    $conn->query( "SET ROLE $safeRole", __METHOD__ );
185                }
186
187                return $status;
188            default:
189                throw new InvalidArgumentException( "Invalid special connection type: \"$type\"" );
190        }
191    }
192
193    public function openConnectionToAnyDB( $user, $password ) {
194        $dbs = [
195            'template1',
196            'postgres',
197        ];
198        if ( !in_array( $this->getVar( 'wgDBname' ), $dbs ) ) {
199            array_unshift( $dbs, $this->getVar( 'wgDBname' ) );
200        }
201        $conn = false;
202        $status = new ConnectionStatus;
203        foreach ( $dbs as $db ) {
204            try {
205                $p = [
206                    'host' => $this->getVar( 'wgDBserver' ),
207                    'port' => $this->getVar( 'wgDBport' ),
208                    'user' => $user,
209                    'password' => $password,
210                    'ssl' => $this->getVar( 'wgDBssl' ),
211                    'dbname' => $db
212                ];
213                $conn = ( new DatabaseFactory() )->create( 'postgres', $p );
214            } catch ( DBConnectionError $error ) {
215                $conn = false;
216                $status->fatal( 'config-pg-test-error', $db,
217                    $error->getMessage() );
218            }
219            if ( $conn !== false ) {
220                break;
221            }
222        }
223        if ( $conn !== false ) {
224            return new ConnectionStatus( $conn );
225        } else {
226            return $status;
227        }
228    }
229
230    protected function getInstallUserPermissions() {
231        $status = $this->getPgConnection( 'create-db' );
232        if ( !$status->isOK() ) {
233            return false;
234        }
235        $conn = $status->getDB();
236        $superuser = $this->getVar( '_InstallUser' );
237
238        $row = $conn->selectRow( '"pg_catalog"."pg_roles"', '*',
239            [ 'rolname' => $superuser ], __METHOD__ );
240
241        return $row;
242    }
243
244    public function canCreateAccounts() {
245        $perms = $this->getInstallUserPermissions();
246        return ( $perms && $perms->rolsuper ) || $perms->rolcreaterole;
247    }
248
249    protected function isSuperUser() {
250        $perms = $this->getInstallUserPermissions();
251        return $perms && $perms->rolsuper;
252    }
253
254    /**
255     * Returns true if the install user is able to create objects owned
256     * by the web user, false otherwise.
257     * @return bool
258     */
259    public function canCreateObjectsForWebUser() {
260        if ( $this->isSuperUser() ) {
261            return true;
262        }
263
264        $status = $this->getPgConnection( 'create-db' );
265        if ( !$status->isOK() ) {
266            return false;
267        }
268        $conn = $status->value;
269        $installerId = $conn->selectField( '"pg_catalog"."pg_roles"', 'oid',
270            [ 'rolname' => $this->getVar( '_InstallUser' ) ], __METHOD__ );
271        $webId = $conn->selectField( '"pg_catalog"."pg_roles"', 'oid',
272            [ 'rolname' => $this->getVar( 'wgDBuser' ) ], __METHOD__ );
273
274        return $this->isRoleMember( $conn, $installerId, $webId, $this->maxRoleSearchDepth );
275    }
276
277    /**
278     * Recursive helper for canCreateObjectsForWebUser().
279     * @param Database $conn
280     * @param int $targetMember Role ID of the member to look for
281     * @param int $group Role ID of the group to look for
282     * @param int $maxDepth Maximum recursive search depth
283     * @return bool
284     */
285    protected function isRoleMember( $conn, $targetMember, $group, $maxDepth ) {
286        if ( $targetMember === $group ) {
287            // A role is always a member of itself
288            return true;
289        }
290        // Get all members of the given group
291        $res = $conn->select( '"pg_catalog"."pg_auth_members"', [ 'member' ],
292            [ 'roleid' => $group ], __METHOD__ );
293        foreach ( $res as $row ) {
294            if ( $row->member == $targetMember ) {
295                // Found target member
296                return true;
297            }
298            // Recursively search each member of the group to see if the target
299            // is a member of it, up to the given maximum depth.
300            if ( $maxDepth > 0 &&
301                $this->isRoleMember( $conn, $targetMember, $row->member, $maxDepth - 1 )
302            ) {
303                // Found member of member
304                return true;
305            }
306        }
307
308        return false;
309    }
310
311    public function preInstall() {
312        $createDbAccount = [
313            'name' => 'user',
314            'callback' => [ $this, 'setupUser' ],
315        ];
316        $commitCB = [
317            'name' => 'pg-commit',
318            'callback' => [ $this, 'commitChanges' ],
319        ];
320        $plpgCB = [
321            'name' => 'pg-plpgsql',
322            'callback' => [ $this, 'setupPLpgSQL' ],
323        ];
324        $schemaCB = [
325            'name' => 'schema',
326            'callback' => [ $this, 'setupSchema' ]
327        ];
328
329        if ( $this->getVar( '_CreateDBAccount' ) ) {
330            $this->parent->addInstallStep( $createDbAccount, 'database' );
331        }
332        $this->parent->addInstallStep( $commitCB, 'interwiki' );
333        $this->parent->addInstallStep( $plpgCB, 'database' );
334        $this->parent->addInstallStep( $schemaCB, 'database' );
335    }
336
337    public function setupDatabase() {
338        $status = $this->getPgConnection( 'create-db' );
339        if ( !$status->isOK() ) {
340            return $status;
341        }
342        $conn = $status->value;
343
344        $dbName = $this->getVar( 'wgDBname' );
345
346        $exists = (bool)$conn->selectField( '"pg_catalog"."pg_database"', '1',
347            [ 'datname' => $dbName ], __METHOD__ );
348        if ( !$exists ) {
349            $safedb = $conn->addIdentifierQuotes( $dbName );
350            $conn->query( "CREATE DATABASE $safedb", __METHOD__ );
351        }
352
353        return Status::newGood();
354    }
355
356    public function setupSchema() {
357        // Get a connection to the target database
358        $status = $this->getPgConnection( 'create-schema' );
359        if ( !$status->isOK() ) {
360            return $status;
361        }
362        $conn = $status->getDB();
363        '@phan-var DatabasePostgres $conn'; /** @var DatabasePostgres $conn */
364
365        // Create the schema if necessary
366        $schema = $this->getVar( 'wgDBmwschema' );
367        $safeschema = $conn->addIdentifierQuotes( $schema );
368        $safeuser = $conn->addIdentifierQuotes( $this->getVar( 'wgDBuser' ) );
369        if ( !$conn->schemaExists( $schema ) ) {
370            try {
371                $conn->query( "CREATE SCHEMA $safeschema AUTHORIZATION $safeuser", __METHOD__ );
372            } catch ( DBQueryError $e ) {
373                return Status::newFatal( 'config-install-pg-schema-failed',
374                    $this->getVar( '_InstallUser' ), $schema );
375            }
376        }
377
378        // Select the new schema in the current connection
379        $conn->determineCoreSchema( $schema );
380
381        return Status::newGood();
382    }
383
384    public function commitChanges() {
385        $this->db->commit( __METHOD__ );
386
387        return Status::newGood();
388    }
389
390    public function setupUser() {
391        if ( !$this->getVar( '_CreateDBAccount' ) ) {
392            return Status::newGood();
393        }
394
395        $status = $this->getPgConnection( 'create-db' );
396        if ( !$status->isOK() ) {
397            return $status;
398        }
399        $conn = $status->getDB();
400        '@phan-var DatabasePostgres $conn'; /** @var DatabasePostgres $conn */
401
402        $safeuser = $conn->addIdentifierQuotes( $this->getVar( 'wgDBuser' ) );
403        $safepass = $conn->addQuotes( $this->getVar( 'wgDBpassword' ) );
404
405        // Check if the user already exists
406        $userExists = $conn->roleExists( $this->getVar( 'wgDBuser' ) );
407        if ( !$userExists ) {
408            // Create the user
409            try {
410                $sql = "CREATE ROLE $safeuser NOCREATEDB LOGIN PASSWORD $safepass";
411
412                // If the install user is not a superuser, we need to make the install
413                // user a member of the new user's group, so that the install user will
414                // be able to create a schema and other objects on behalf of the new user.
415                if ( !$this->isSuperUser() ) {
416                    $sql .= ' ROLE' . $conn->addIdentifierQuotes( $this->getVar( '_InstallUser' ) );
417                }
418
419                $conn->query( $sql, __METHOD__ );
420            } catch ( DBQueryError $e ) {
421                return Status::newFatal( 'config-install-user-create-failed',
422                    $this->getVar( 'wgDBuser' ), $e->getMessage() );
423            }
424        }
425
426        return Status::newGood();
427    }
428
429    public function getLocalSettings() {
430        $port = $this->getVar( 'wgDBport' );
431        $useSsl = $this->getVar( 'wgDBssl' ) ? 'true' : 'false';
432        $schema = $this->getVar( 'wgDBmwschema' );
433
434        return "# Postgres specific settings
435\$wgDBport = \"{$port}\";
436\$wgDBssl = {$useSsl};
437\$wgDBmwschema = \"{$schema}\";";
438    }
439
440    public function preUpgrade() {
441        global $wgDBuser, $wgDBpassword;
442
443        # Normal user and password are selected after this step, so for now
444        # just copy these two
445        $wgDBuser = $this->getVar( '_InstallUser' );
446        $wgDBpassword = $this->getVar( '_InstallPassword' );
447    }
448
449    public function createTables() {
450        $schema = $this->getVar( 'wgDBmwschema' );
451
452        $status = $this->getConnection();
453        if ( !$status->isOK() ) {
454            return $status;
455        }
456        $conn = $status->getDB();
457        '@phan-var DatabasePostgres $conn'; /** @var DatabasePostgres $conn */
458
459        if ( $conn->tableExists( 'archive', __METHOD__ ) ) {
460            $status->warning( 'config-install-tables-exist' );
461            $this->enableLB();
462
463            return $status;
464        }
465
466        $conn->begin( __METHOD__ );
467
468        if ( !$conn->schemaExists( $schema ) ) {
469            $status->fatal( 'config-install-pg-schema-not-exist' );
470
471            return $status;
472        }
473
474        $error = $conn->sourceFile( $this->getGeneratedSchemaPath( $conn ) );
475        if ( $error !== true ) {
476            $conn->reportQueryError( $error, 0, '', __METHOD__ );
477            $conn->rollback( __METHOD__ );
478            $status->fatal( 'config-install-tables-failed', $error );
479        } else {
480            $error = $conn->sourceFile( $this->getSchemaPath( $conn ) );
481            if ( $error !== true ) {
482                $conn->reportQueryError( $error, 0, '', __METHOD__ );
483                $conn->rollback( __METHOD__ );
484                $status->fatal( 'config-install-tables-manual-failed', $error );
485            } else {
486                $conn->commit( __METHOD__ );
487            }
488        }
489        // Resume normal operations
490        if ( $status->isOK() ) {
491            $this->enableLB();
492        }
493
494        return $status;
495    }
496
497    public function createManualTables() {
498        // Already handled above. Do nothing.
499        return Status::newGood();
500    }
501
502    public function getGlobalDefaults() {
503        // The default $wgDBmwschema is null, which breaks Postgres and other DBMSes that require
504        // the use of a schema, so we need to set it here
505        return array_merge( parent::getGlobalDefaults(), [
506            'wgDBmwschema' => 'mediawiki',
507        ] );
508    }
509
510    public function setupPLpgSQL() {
511        // Connect as the install user, since it owns the database and so is
512        // the user that needs to run "CREATE LANGUAGE"
513        $status = $this->getPgConnection( 'create-schema' );
514        if ( !$status->isOK() ) {
515            return $status;
516        }
517        $conn = $status->getDB();
518
519        $exists = (bool)$conn->selectField( '"pg_catalog"."pg_language"', '1',
520            [ 'lanname' => 'plpgsql' ], __METHOD__ );
521        if ( $exists ) {
522            // Already exists, nothing to do
523            return Status::newGood();
524        }
525
526        // plpgsql is not installed, but if we have a pg_pltemplate table, we
527        // should be able to create it
528        $exists = (bool)$conn->selectField(
529            [ '"pg_catalog"."pg_class"', '"pg_catalog"."pg_namespace"' ],
530            '1',
531            [
532                'pg_namespace.oid=relnamespace',
533                'nspname' => 'pg_catalog',
534                'relname' => 'pg_pltemplate',
535            ],
536            __METHOD__ );
537        if ( $exists ) {
538            try {
539                $conn->query( 'CREATE LANGUAGE plpgsql', __METHOD__ );
540            } catch ( DBQueryError $e ) {
541                return Status::newFatal( 'config-pg-no-plpgsql', $this->getVar( 'wgDBname' ) );
542            }
543        } else {
544            return Status::newFatal( 'config-pg-no-plpgsql', $this->getVar( 'wgDBname' ) );
545        }
546
547        return Status::newGood();
548    }
549}