Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 228
0.00% covered (danger)
0.00%
0 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
MysqlInstaller
0.00% covered (danger)
0.00%
0 / 228
0.00% covered (danger)
0.00%
0 / 21
4160
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
 meetsMinimumRequirement
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 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 preUpgrade
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
132
 escapeLikeInternal
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getEngines
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getCharsets
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 canCreateAccounts
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
156
 likeToRegex
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 preInstall
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 setupDatabase
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 databaseExists
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 setupUser
0.00% covered (danger)
0.00%
0 / 64
0.00% covered (danger)
0.00%
0 / 1
156
 buildFullUserName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 userDefinitelyExists
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getTableOptions
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getSchemaVars
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getLocalSettings
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3/**
4 * MySQL-specific installer.
5 *
6 * This program is free software; you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation; either version 2 of the License, or
9 * (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License along
17 * with this program; if not, write to the Free Software Foundation, Inc.,
18 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 * http://www.gnu.org/copyleft/gpl.html
20 *
21 * @file
22 * @ingroup Installer
23 */
24
25namespace MediaWiki\Installer;
26
27use MediaWiki\Status\Status;
28use Wikimedia\Rdbms\DatabaseFactory;
29use Wikimedia\Rdbms\DatabaseMySQL;
30use Wikimedia\Rdbms\DBConnectionError;
31use Wikimedia\Rdbms\DBQueryError;
32use Wikimedia\Rdbms\IDatabase;
33
34/**
35 * Class for setting up the MediaWiki database using MySQL.
36 *
37 * @ingroup Installer
38 * @since 1.17
39 */
40class MysqlInstaller extends DatabaseInstaller {
41
42    protected $globalNames = [
43        'wgDBserver',
44        'wgDBname',
45        'wgDBuser',
46        'wgDBpassword',
47        'wgDBssl',
48        'wgDBprefix',
49        'wgDBTableOptions',
50    ];
51
52    protected $internalDefaults = [
53        '_MysqlEngine' => 'InnoDB',
54        '_MysqlCharset' => 'binary',
55        '_InstallUser' => 'root',
56    ];
57
58    public $supportedEngines = [ 'InnoDB' ];
59
60    private const MIN_VERSIONS = [
61        'MySQL' => '5.7.0',
62        'MariaDB' => '10.3',
63    ];
64    public static $minimumVersion;
65    protected static $notMinimumVersionMessage;
66
67    public $webUserPrivs = [
68        'DELETE',
69        'INSERT',
70        'SELECT',
71        'UPDATE',
72        'CREATE TEMPORARY TABLES',
73    ];
74
75    /**
76     * @return string
77     */
78    public function getName() {
79        return 'mysql';
80    }
81
82    /**
83     * @return bool
84     */
85    public function isCompiled() {
86        return self::checkExtension( 'mysqli' );
87    }
88
89    public function getConnectForm( WebInstaller $webInstaller ): DatabaseConnectForm {
90        return new MysqlConnectForm( $webInstaller, $this );
91    }
92
93    public function getSettingsForm( WebInstaller $webInstaller ): DatabaseSettingsForm {
94        return new MysqlSettingsForm( $webInstaller, $this );
95    }
96
97    public static function meetsMinimumRequirement( IDatabase $conn ) {
98        $type = str_contains( $conn->getSoftwareLink(), 'MariaDB' ) ? 'MariaDB' : 'MySQL';
99        self::$minimumVersion = self::MIN_VERSIONS[$type];
100        // Used messages: config-mysql-old, config-mariadb-old
101        self::$notMinimumVersionMessage = 'config-' . strtolower( $type ) . '-old';
102        return parent::meetsMinimumRequirement( $conn );
103    }
104
105    /**
106     * @return ConnectionStatus
107     */
108    public function openConnection() {
109        $status = new ConnectionStatus;
110        try {
111            /** @var DatabaseMySQL $db */
112            $db = ( new DatabaseFactory() )->create( 'mysql', [
113                'host' => $this->getVar( 'wgDBserver' ),
114                'user' => $this->getVar( '_InstallUser' ),
115                'password' => $this->getVar( '_InstallPassword' ),
116                'ssl' => $this->getVar( 'wgDBssl' ),
117                'dbname' => null,
118                'flags' => 0,
119                'tablePrefix' => $this->getVar( 'wgDBprefix' ) ] );
120            $status->setDB( $db );
121        } catch ( DBConnectionError $e ) {
122            $status->fatal( 'config-connection-error', $e->getMessage() );
123        }
124
125        return $status;
126    }
127
128    public function preUpgrade() {
129        global $wgDBuser, $wgDBpassword;
130
131        $status = $this->getConnection();
132        if ( !$status->isOK() ) {
133            $this->parent->showStatusMessage( $status );
134
135            return;
136        }
137        $conn = $status->getDB();
138        $this->selectDatabase( $conn, $this->getVar( 'wgDBname' ) );
139        # Determine existing default character set
140        if ( $conn->tableExists( "revision", __METHOD__ ) ) {
141            $revision = $this->escapeLikeInternal( $this->getVar( 'wgDBprefix' ) . 'revision', '\\' );
142            $res = $conn->query( "SHOW TABLE STATUS LIKE '$revision'", __METHOD__ );
143            $row = $res->fetchObject();
144            if ( !$row ) {
145                $this->parent->showMessage( 'config-show-table-status' );
146                $existingSchema = false;
147                $existingEngine = false;
148            } else {
149                if ( preg_match( '/^latin1/', $row->Collation ) ) {
150                    $existingSchema = 'latin1';
151                } elseif ( preg_match( '/^utf8/', $row->Collation ) ) {
152                    $existingSchema = 'utf8';
153                } elseif ( preg_match( '/^binary/', $row->Collation ) ) {
154                    $existingSchema = 'binary';
155                } else {
156                    $existingSchema = false;
157                    $this->parent->showMessage( 'config-unknown-collation' );
158                }
159                $existingEngine = $row->Engine ?? $row->Type;
160            }
161        } else {
162            $existingSchema = false;
163            $existingEngine = false;
164        }
165
166        if ( $existingSchema && $existingSchema != $this->getVar( '_MysqlCharset' ) ) {
167            $this->setVar( '_MysqlCharset', $existingSchema );
168        }
169        if ( $existingEngine && $existingEngine != $this->getVar( '_MysqlEngine' ) ) {
170            $this->setVar( '_MysqlEngine', $existingEngine );
171        }
172
173        # Normal user and password are selected after this step, so for now
174        # just copy these two
175        $wgDBuser = $this->getVar( '_InstallUser' );
176        $wgDBpassword = $this->getVar( '_InstallPassword' );
177    }
178
179    /**
180     * @param string $s
181     * @param string $escapeChar
182     * @return string
183     */
184    protected function escapeLikeInternal( $s, $escapeChar = '`' ) {
185        return str_replace( [ $escapeChar, '%', '_' ],
186            [ "{$escapeChar}{$escapeChar}", "{$escapeChar}%", "{$escapeChar}_" ],
187            $s );
188    }
189
190    /**
191     * Get a list of storage engines that are available and supported
192     *
193     * @return array
194     */
195    public function getEngines() {
196        $status = $this->getConnection();
197        $conn = $status->getDB();
198
199        $engines = [];
200        $res = $conn->query( 'SHOW ENGINES', __METHOD__ );
201        foreach ( $res as $row ) {
202            if ( $row->Support == 'YES' || $row->Support == 'DEFAULT' ) {
203                $engines[] = $row->Engine;
204            }
205        }
206        $engines = array_intersect( $this->supportedEngines, $engines );
207
208        return $engines;
209    }
210
211    /**
212     * Get a list of character sets that are available and supported
213     *
214     * @return array
215     */
216    public function getCharsets() {
217        return [ 'binary', 'utf8' ];
218    }
219
220    /**
221     * Return true if the install user can create accounts
222     *
223     * @return bool
224     */
225    public function canCreateAccounts() {
226        $status = $this->getConnection();
227        if ( !$status->isOK() ) {
228            return false;
229        }
230        $conn = $status->getDB();
231
232        // Get current account name
233        $currentName = $conn->selectField( '', 'CURRENT_USER()', '', __METHOD__ );
234        $parts = explode( '@', $currentName );
235        if ( count( $parts ) != 2 ) {
236            return false;
237        }
238        $quotedUser = $conn->addQuotes( $parts[0] ) .
239            '@' . $conn->addQuotes( $parts[1] );
240
241        // The user needs to have INSERT on mysql.* to be able to CREATE USER
242        // The grantee will be double-quoted in this query, as required
243        $res = $conn->select( 'INFORMATION_SCHEMA.USER_PRIVILEGES', '*',
244            [ 'GRANTEE' => $quotedUser ], __METHOD__ );
245        $insertMysql = false;
246        $grantOptions = array_fill_keys( $this->webUserPrivs, true );
247        foreach ( $res as $row ) {
248            if ( $row->PRIVILEGE_TYPE == 'INSERT' ) {
249                $insertMysql = true;
250            }
251            if ( $row->IS_GRANTABLE ) {
252                unset( $grantOptions[$row->PRIVILEGE_TYPE] );
253            }
254        }
255
256        // Check for DB-specific privs for mysql.*
257        if ( !$insertMysql ) {
258            $row = $conn->selectRow( 'INFORMATION_SCHEMA.SCHEMA_PRIVILEGES', '*',
259                [
260                    'GRANTEE' => $quotedUser,
261                    'TABLE_SCHEMA' => 'mysql',
262                    'PRIVILEGE_TYPE' => 'INSERT',
263                ], __METHOD__ );
264            if ( $row ) {
265                $insertMysql = true;
266            }
267        }
268
269        if ( !$insertMysql ) {
270            return false;
271        }
272
273        // Check for DB-level grant options
274        $res = $conn->select( 'INFORMATION_SCHEMA.SCHEMA_PRIVILEGES', '*',
275            [
276                'GRANTEE' => $quotedUser,
277                'IS_GRANTABLE' => 1,
278            ], __METHOD__ );
279        foreach ( $res as $row ) {
280            $regex = $this->likeToRegex( $row->TABLE_SCHEMA );
281            if ( preg_match( $regex, $this->getVar( 'wgDBname' ) ) ) {
282                unset( $grantOptions[$row->PRIVILEGE_TYPE] );
283            }
284        }
285        if ( count( $grantOptions ) ) {
286            // Can't grant everything
287            return false;
288        }
289
290        return true;
291    }
292
293    /**
294     * Convert a wildcard (as used in LIKE) to a regex
295     * Slashes are escaped, slash terminators included
296     * @param string $wildcard
297     * @return string
298     */
299    protected function likeToRegex( $wildcard ) {
300        $r = preg_quote( $wildcard, '/' );
301        $r = strtr( $r, [
302            '%' => '.*',
303            '_' => '.'
304        ] );
305        return "/$r/s";
306    }
307
308    public function preInstall() {
309        # Add our user callback to installSteps, right before the tables are created.
310        $callback = [
311            'name' => 'user',
312            'callback' => [ $this, 'setupUser' ],
313        ];
314        $this->parent->addInstallStep( $callback, 'tables' );
315    }
316
317    /**
318     * @return Status
319     */
320    public function setupDatabase() {
321        $status = $this->getConnection();
322        if ( !$status->isOK() ) {
323            return $status;
324        }
325        $conn = $status->getDB();
326        $dbName = $this->getVar( 'wgDBname' );
327        if ( !$this->databaseExists( $dbName ) ) {
328            $conn->query(
329                "CREATE DATABASE " . $conn->addIdentifierQuotes( $dbName ) . "CHARACTER SET utf8",
330                __METHOD__
331            );
332        }
333        $this->selectDatabase( $conn, $dbName );
334        $this->setupSchemaVars();
335
336        return $status;
337    }
338
339    /**
340     * Try to see if a given database exists
341     * @param string $dbName Database name to check
342     * @return bool
343     */
344    private function databaseExists( $dbName ) {
345        $encDatabase = $this->db->addQuotes( $dbName );
346
347        return $this->db->query(
348            "SELECT 1 FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = $encDatabase",
349            __METHOD__
350        )->numRows() > 0;
351    }
352
353    /**
354     * @return Status
355     */
356    public function setupUser() {
357        $dbUser = $this->getVar( 'wgDBuser' );
358        if ( $dbUser == $this->getVar( '_InstallUser' ) ) {
359            return Status::newGood();
360        }
361        $status = $this->getConnection();
362        if ( !$status->isOK() ) {
363            return $status;
364        }
365
366        $this->setupSchemaVars();
367        $dbName = $this->getVar( 'wgDBname' );
368        $this->selectDatabase( $this->db, $dbName );
369        $server = $this->getVar( 'wgDBserver' );
370        $password = $this->getVar( 'wgDBpassword' );
371        $grantableNames = [];
372
373        if ( $this->getVar( '_CreateDBAccount' ) ) {
374            // Before we blindly try to create a user that already has access,
375            try { // first attempt to connect to the database
376                ( new DatabaseFactory() )->create( 'mysql', [
377                    'host' => $server,
378                    'user' => $dbUser,
379                    'password' => $password,
380                    'ssl' => $this->getVar( 'wgDBssl' ),
381                    'dbname' => null,
382                    'flags' => 0,
383                    'tablePrefix' => $this->getVar( 'wgDBprefix' )
384                ] );
385                $grantableNames[] = $this->buildFullUserName( $dbUser, $server );
386                $tryToCreate = false;
387            } catch ( DBConnectionError $e ) {
388                $tryToCreate = true;
389            }
390        } else {
391            $grantableNames[] = $this->buildFullUserName( $dbUser, $server );
392            $tryToCreate = false;
393        }
394
395        if ( $tryToCreate ) {
396            $createHostList = [
397                $server,
398                'localhost',
399                'localhost.localdomain',
400                '%'
401            ];
402
403            $createHostList = array_unique( $createHostList );
404            $escPass = $this->db->addQuotes( $password );
405
406            foreach ( $createHostList as $host ) {
407                $fullName = $this->buildFullUserName( $dbUser, $host );
408                if ( !$this->userDefinitelyExists( $host, $dbUser ) ) {
409                    try {
410                        $this->db->begin( __METHOD__ );
411                        $this->db->query( "CREATE USER $fullName IDENTIFIED BY $escPass", __METHOD__ );
412                        $this->db->commit( __METHOD__ );
413                        $grantableNames[] = $fullName;
414                    } catch ( DBQueryError $dqe ) {
415                        if ( $this->db->lastErrno() == 1396 /* ER_CANNOT_USER */ ) {
416                            // User (probably) already exists
417                            $this->db->rollback( __METHOD__ );
418                            $status->warning( 'config-install-user-alreadyexists', $dbUser );
419                            $grantableNames[] = $fullName;
420                            break;
421                        } else {
422                            // If we couldn't create for some bizarre reason and the
423                            // user probably doesn't exist, skip the grant
424                            $this->db->rollback( __METHOD__ );
425                            $status->warning( 'config-install-user-create-failed', $dbUser, $dqe->getMessage() );
426                        }
427                    }
428                } else {
429                    $status->warning( 'config-install-user-alreadyexists', $dbUser );
430                    $grantableNames[] = $fullName;
431                    break;
432                }
433            }
434        }
435
436        // Try to grant to all the users we know exist or we were able to create
437        $dbAllTables = $this->db->addIdentifierQuotes( $dbName ) . '.*';
438        foreach ( $grantableNames as $name ) {
439            try {
440                $this->db->begin( __METHOD__ );
441                $this->db->query( "GRANT ALL PRIVILEGES ON $dbAllTables TO $name", __METHOD__ );
442                $this->db->commit( __METHOD__ );
443            } catch ( DBQueryError $dqe ) {
444                $this->db->rollback( __METHOD__ );
445                $status->fatal( 'config-install-user-grant-failed', $dbUser, $dqe->getMessage() );
446            }
447        }
448
449        return $status;
450    }
451
452    /**
453     * Return a formal 'User'@'Host' username for use in queries
454     * @param string $name Username, quotes will be added
455     * @param string $host Hostname, quotes will be added
456     * @return string
457     */
458    private function buildFullUserName( $name, $host ) {
459        return $this->db->addQuotes( $name ) . '@' . $this->db->addQuotes( $host );
460    }
461
462    /**
463     * Try to see if the user account exists. Our "superuser" may not have
464     * access to mysql.user, so false means "no" or "maybe"
465     * @param string $host Hostname to check
466     * @param string $user Username to check
467     * @return bool
468     */
469    private function userDefinitelyExists( $host, $user ) {
470        try {
471            $res = $this->db->newSelectQueryBuilder()
472                ->select( [ 'Host', 'User' ] )
473                ->from( 'mysql.user' )
474                ->where( [ 'Host' => $host, 'User' => $user ] )
475                ->caller( __METHOD__ )->fetchRow();
476
477            return (bool)$res;
478        } catch ( DBQueryError $dqe ) {
479            return false;
480        }
481    }
482
483    /**
484     * Return any table options to be applied to all tables that don't
485     * override them.
486     *
487     * @return string
488     */
489    protected function getTableOptions() {
490        $options = [];
491        if ( $this->getVar( '_MysqlEngine' ) !== null ) {
492            $options[] = "ENGINE=" . $this->getVar( '_MysqlEngine' );
493        }
494        if ( $this->getVar( '_MysqlCharset' ) !== null ) {
495            $options[] = 'DEFAULT CHARSET=' . $this->getVar( '_MysqlCharset' );
496        }
497
498        return implode( ', ', $options );
499    }
500
501    /**
502     * Get variables to substitute into tables.sql and the SQL patch files.
503     *
504     * @return array
505     */
506    public function getSchemaVars() {
507        return [
508            'wgDBTableOptions' => $this->getTableOptions(),
509            'wgDBname' => $this->getVar( 'wgDBname' ),
510            'wgDBuser' => $this->getVar( 'wgDBuser' ),
511            'wgDBpassword' => $this->getVar( 'wgDBpassword' ),
512        ];
513    }
514
515    public function getLocalSettings() {
516        $prefix = LocalSettingsGenerator::escapePhpString( $this->getVar( 'wgDBprefix' ) );
517        $useSsl = $this->getVar( 'wgDBssl' ) ? 'true' : 'false';
518        $tblOpts = LocalSettingsGenerator::escapePhpString( $this->getTableOptions() );
519
520        return "# MySQL specific settings
521\$wgDBprefix = \"{$prefix}\";
522\$wgDBssl = {$useSsl};
523
524# MySQL table options to use during installation or update
525\$wgDBTableOptions = \"{$tblOpts}\";";
526    }
527}