Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 132
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
MysqlInstaller
0.00% covered (danger)
0.00%
0 / 132
0.00% covered (danger)
0.00%
0 / 15
2162
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 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 preUpgrade
0.00% covered (danger)
0.00%
0 / 30
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 / 44
0.00% covered (danger)
0.00%
0 / 1
182
 likeToRegex
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 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 / 3
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 * @license GPL-2.0-or-later
7 * @file
8 * @ingroup Installer
9 */
10
11namespace MediaWiki\Installer;
12
13use Wikimedia\Rdbms\DatabaseFactory;
14use Wikimedia\Rdbms\DatabaseMySQL;
15use Wikimedia\Rdbms\DBConnectionError;
16use Wikimedia\Rdbms\IDatabase;
17
18/**
19 * Class for setting up the MediaWiki database using MySQL.
20 *
21 * @ingroup Installer
22 * @since 1.17
23 */
24class MysqlInstaller extends DatabaseInstaller {
25
26    /** @inheritDoc */
27    protected $globalNames = [
28        'wgDBserver',
29        'wgDBname',
30        'wgDBuser',
31        'wgDBpassword',
32        'wgDBssl',
33        'wgDBprefix',
34        'wgDBTableOptions',
35    ];
36
37    /** @inheritDoc */
38    protected $internalDefaults = [
39        '_MysqlEngine' => 'InnoDB',
40        '_MysqlCharset' => 'binary',
41        '_InstallUser' => 'root',
42    ];
43
44    /** @var string[] */
45    public $supportedEngines = [ 'InnoDB' ];
46
47    private const MIN_VERSIONS = [
48        'MySQL' => '5.7.0',
49        'MariaDB' => '10.11',
50    ];
51    /** @inheritDoc */
52    public static $minimumVersion;
53    /** @inheritDoc */
54    protected static $notMinimumVersionMessage;
55
56    /** @var string[] */
57    public $webUserPrivs = [
58        'DELETE',
59        'INSERT',
60        'SELECT',
61        'UPDATE',
62        'CREATE TEMPORARY TABLES',
63    ];
64
65    /**
66     * @return string
67     */
68    public function getName() {
69        return 'mysql';
70    }
71
72    /**
73     * @return bool
74     */
75    public function isCompiled() {
76        return self::checkExtension( 'mysqli' );
77    }
78
79    public function getConnectForm( WebInstaller $webInstaller ): DatabaseConnectForm {
80        return new MysqlConnectForm( $webInstaller, $this );
81    }
82
83    public function getSettingsForm( WebInstaller $webInstaller ): DatabaseSettingsForm {
84        return new MysqlSettingsForm( $webInstaller, $this );
85    }
86
87    /** @inheritDoc */
88    public static function meetsMinimumRequirement( IDatabase $conn ) {
89        $type = str_contains( $conn->getSoftwareLink(), 'MariaDB' ) ? 'MariaDB' : 'MySQL';
90        self::$minimumVersion = self::MIN_VERSIONS[$type];
91        // Used messages: config-mysql-old, config-mariadb-old
92        self::$notMinimumVersionMessage = 'config-' . strtolower( $type ) . '-old';
93        return parent::meetsMinimumRequirement( $conn );
94    }
95
96    /**
97     * @param string $type
98     * @return ConnectionStatus
99     */
100    protected function openConnection( string $type ) {
101        $status = new ConnectionStatus;
102        $dbName = $type === DatabaseInstaller::CONN_CREATE_DATABASE
103            ? null : $this->getVar( 'wgDBname' );
104        try {
105            /** @var DatabaseMySQL $db */
106            $db = ( new DatabaseFactory() )->create( 'mysql', [
107                'host' => $this->getVar( 'wgDBserver' ),
108                'user' => $this->getVar( '_InstallUser' ),
109                'password' => $this->getVar( '_InstallPassword' ),
110                'ssl' => $this->getVar( 'wgDBssl' ),
111                'dbname' => $dbName,
112                'flags' => 0,
113                'tablePrefix' => $this->getVar( 'wgDBprefix' ) ] );
114            $status->setDB( $db );
115        } catch ( DBConnectionError $e ) {
116            $status->fatal( 'config-connection-error', $e->getMessage() );
117        }
118
119        return $status;
120    }
121
122    public function preUpgrade() {
123        global $wgDBuser, $wgDBpassword;
124
125        $status = $this->getConnection( self::CONN_CREATE_TABLES );
126        if ( !$status->isOK() ) {
127            $this->parent->showStatusMessage( $status );
128
129            return;
130        }
131        $conn = $status->getDB();
132        # Determine existing default character set
133        if ( $conn->tableExists( "revision", __METHOD__ ) ) {
134            $revision = $this->escapeLikeInternal( $this->getVar( 'wgDBprefix' ) . 'revision', '\\' );
135            $res = $conn->query( "SHOW TABLE STATUS LIKE '$revision'", __METHOD__ );
136            $row = $res->fetchObject();
137            if ( !$row ) {
138                $this->parent->showMessage( 'config-show-table-status' );
139                $existingSchema = false;
140                $existingEngine = false;
141            } else {
142                if ( preg_match( '/^latin1/', $row->Collation ) ) {
143                    $existingSchema = 'latin1';
144                } elseif ( preg_match( '/^utf8/', $row->Collation ) ) {
145                    $existingSchema = 'utf8';
146                } elseif ( preg_match( '/^binary/', $row->Collation ) ) {
147                    $existingSchema = 'binary';
148                } else {
149                    $existingSchema = false;
150                    $this->parent->showMessage( 'config-unknown-collation' );
151                }
152                $existingEngine = $row->Engine ?? $row->Type;
153            }
154        } else {
155            $existingSchema = false;
156            $existingEngine = false;
157        }
158
159        if ( $existingSchema && $existingSchema != $this->getVar( '_MysqlCharset' ) ) {
160            $this->setVar( '_MysqlCharset', $existingSchema );
161        }
162        if ( $existingEngine && $existingEngine != $this->getVar( '_MysqlEngine' ) ) {
163            $this->setVar( '_MysqlEngine', $existingEngine );
164        }
165
166        # Normal user and password are selected after this step, so for now
167        # just copy these two
168        $wgDBuser = $this->getVar( '_InstallUser' );
169        $wgDBpassword = $this->getVar( '_InstallPassword' );
170    }
171
172    /**
173     * @param string $s
174     * @param string $escapeChar
175     * @return string
176     */
177    protected function escapeLikeInternal( $s, $escapeChar = '`' ) {
178        return str_replace( [ $escapeChar, '%', '_' ],
179            [ "{$escapeChar}{$escapeChar}", "{$escapeChar}%", "{$escapeChar}_" ],
180            $s );
181    }
182
183    /**
184     * Get a list of storage engines that are available and supported
185     *
186     * @return array
187     */
188    public function getEngines() {
189        $status = $this->getConnection( self::CONN_CREATE_DATABASE );
190        $conn = $status->getDB();
191
192        $engines = [];
193        $res = $conn->query( 'SHOW ENGINES', __METHOD__ );
194        foreach ( $res as $row ) {
195            if ( $row->Support == 'YES' || $row->Support == 'DEFAULT' ) {
196                $engines[] = $row->Engine;
197            }
198        }
199        $engines = array_intersect( $this->supportedEngines, $engines );
200
201        return $engines;
202    }
203
204    /**
205     * Get a list of character sets that are available and supported
206     *
207     * @return array
208     */
209    public function getCharsets() {
210        return [ 'binary', 'utf8' ];
211    }
212
213    /**
214     * Return true if the install user can create accounts
215     *
216     * @return bool
217     */
218    public function canCreateAccounts() {
219        $status = $this->getConnection( self::CONN_CREATE_DATABASE );
220        if ( !$status->isOK() ) {
221            return false;
222        }
223        $conn = $status->getDB();
224
225        // Get current account name
226        $currentName = $conn->selectField( '', 'CURRENT_USER()', '', __METHOD__ );
227        $parts = explode( '@', $currentName );
228        if ( count( $parts ) != 2 ) {
229            return false;
230        }
231        $quotedUser = $conn->addQuotes( $parts[0] ) .
232            '@' . $conn->addQuotes( $parts[1] );
233
234        // The user needs to have INSERT on mysql.* or (global) CREATE USER to be able to CREATE USER
235        // The grantee will be double-quoted in this query, as required
236        $res = $conn->select( 'INFORMATION_SCHEMA.USER_PRIVILEGES', '*',
237            [ 'GRANTEE' => $quotedUser ], __METHOD__ );
238        $insertMysql = false;
239        // The user needs to have permission to GRANT all necessary permissions to the newly created user.
240        // This starts as a list of all necessary permissions and they are removed as they are found.
241        // If any are left in the list after checking for permissions, those are the missing permissions.
242        $missingGrantOptions = array_fill_keys( $this->webUserPrivs, true );
243        foreach ( $res as $row ) {
244            if ( $row->PRIVILEGE_TYPE == 'INSERT' ) {
245                $insertMysql = true;
246            } elseif ( $row->PRIVILEGE_TYPE == 'CREATE USER' ) {
247                $insertMysql = true;
248            }
249            if ( $row->IS_GRANTABLE === 'YES' ) {
250                unset( $missingGrantOptions[$row->PRIVILEGE_TYPE] );
251            }
252        }
253
254        // Check for DB-specific privs for mysql.*
255        if ( !$insertMysql ) {
256            $row = $conn->selectRow( 'INFORMATION_SCHEMA.SCHEMA_PRIVILEGES', '*',
257                [
258                    'GRANTEE' => $quotedUser,
259                    'TABLE_SCHEMA' => 'mysql',
260                    'PRIVILEGE_TYPE' => 'INSERT',
261                ], __METHOD__ );
262            if ( $row ) {
263                $insertMysql = true;
264            }
265        }
266
267        if ( !$insertMysql ) {
268            return false;
269        }
270
271        // Check for DB-level grant options
272        $res = $conn->select( 'INFORMATION_SCHEMA.SCHEMA_PRIVILEGES', '*',
273            [
274                'GRANTEE' => $quotedUser,
275                'IS_GRANTABLE' => 'YES',
276            ], __METHOD__ );
277        foreach ( $res as $row ) {
278            $regex = $this->likeToRegex( $row->TABLE_SCHEMA );
279            if ( preg_match( $regex, $this->getVar( 'wgDBname' ) ) ) {
280                unset( $missingGrantOptions[$row->PRIVILEGE_TYPE] );
281            }
282        }
283        if ( count( $missingGrantOptions ) ) {
284            // Can't grant everything
285            return false;
286        }
287
288        return true;
289    }
290
291    /**
292     * Convert a wildcard (as used in LIKE) to a regex
293     * Slashes are escaped, slash terminators included
294     * @param string $wildcard
295     * @return string
296     */
297    protected function likeToRegex( $wildcard ) {
298        $r = preg_quote( $wildcard, '/' );
299        $r = strtr( $r, [
300            '%' => '.*',
301            '_' => '.'
302        ] );
303        return "/$r/s";
304    }
305
306    /**
307     * Return any table options to be applied to all tables that don't
308     * override them.
309     *
310     * @return string
311     */
312    protected function getTableOptions() {
313        $options = [];
314        if ( $this->getVar( '_MysqlEngine' ) !== null ) {
315            $options[] = "ENGINE=" . $this->getVar( '_MysqlEngine' );
316        }
317        if ( $this->getVar( '_MysqlCharset' ) !== null ) {
318            $options[] = 'DEFAULT CHARSET=' . $this->getVar( '_MysqlCharset' );
319        }
320
321        return implode( ', ', $options );
322    }
323
324    /**
325     * Get variables to substitute into the SQL schema and patch files.
326     *
327     * @return array
328     */
329    public function getSchemaVars() {
330        return [
331            'wgDBTableOptions' => $this->getTableOptions(),
332        ];
333    }
334
335    /** @inheritDoc */
336    public function getLocalSettings() {
337        $prefix = LocalSettingsGenerator::escapePhpString( $this->getVar( 'wgDBprefix' ) );
338        $useSsl = $this->getVar( 'wgDBssl' ) ? 'true' : 'false';
339        $tblOpts = LocalSettingsGenerator::escapePhpString( $this->getTableOptions() );
340
341        return "# MySQL specific settings
342\$wgDBprefix = \"{$prefix}\";
343\$wgDBssl = {$useSsl};
344
345# MySQL table options to use during installation or update
346\$wgDBTableOptions = \"{$tblOpts}\";";
347    }
348}