Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.67% covered (success)
90.67%
136 / 150
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
ResetUserEmail
90.67% covered (success)
90.67%
136 / 150
66.67% covered (warning)
66.67%
4 / 6
39.17
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 resetSingleUser
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 resetFromFile
92.00% covered (success)
92.00%
46 / 50
0.00% covered (danger)
0.00%
0 / 1
16.13
 resolveUser
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 performReset
80.00% covered (warning)
80.00%
40 / 50
0.00% covered (danger)
0.00%
0 / 1
7.39
1<?php
2/**
3 * Reset user email.
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 * @ingroup Maintenance
8 */
9
10// @codeCoverageIgnoreStart
11require_once __DIR__ . '/Maintenance.php';
12// @codeCoverageIgnoreEnd
13
14use MediaWiki\Logger\LoggerFactory;
15use MediaWiki\Maintenance\Maintenance;
16use MediaWiki\Parser\Sanitizer;
17use MediaWiki\Password\PasswordFactory;
18use MediaWiki\User\User;
19
20/**
21 * Maintenance script that resets user email.
22 *
23 * Supports resetting a single user's email via positional arguments,
24 * or batch-resetting multiple users by reading from a file or stdin.
25 *
26 * @since 1.27
27 * @ingroup Maintenance
28 */
29class ResetUserEmail extends Maintenance {
30    public function __construct() {
31        parent::__construct();
32
33        $this->addDescription(
34            "Resets a user's email, or batch resets from a file.\n\n"
35            . "To reset a single user:\n"
36            . "  resetUserEmail.php <user> <email>\n\n"
37            . "To batch reset from a file (one \"<username>\t<email>\" pair per line, tab-separated):\n"
38            . "  resetUserEmail.php --file /path/to/resets.tsv\n\n"
39            . "To batch reset from stdin:\n"
40            . "  resetUserEmail.php --file -"
41        );
42
43        $this->addArg( 'user', 'Username or user ID, if starts with #', false );
44        $this->addArg( 'email', 'Email to assign', false );
45
46        $this->addOption( 'no-reset-password', 'Don\'t reset the user\'s password' );
47        $this->addOption( 'email-password',
48            'Send a temporary password to the user\'s new email address'
49        );
50        $this->addOption( 'reason', 'Reason for the email change (ticket number etc)', false, true );
51        $this->addOption(
52            'file',
53            'File with one "<username>\t<email>" pair per line, tab-separated '
54            . '(# lines are comments). Use - for stdin.',
55            false,
56            true,
57            'f'
58        );
59        $this->setBatchSize( 100 );
60    }
61
62    public function execute() {
63        $file = $this->getOption( 'file' );
64        $userName = $this->getArg( 0 );
65
66        if ( $file !== null && $userName !== null ) {
67            $this->fatalError( 'Cannot use both positional arguments and --file' );
68        }
69
70        if ( $file !== null ) {
71            $this->resetFromFile( $file );
72        } elseif ( $userName !== null ) {
73            $email = $this->getArg( 1, '' );
74            $this->resetSingleUser( $userName, $email );
75        } else {
76            $this->fatalError( 'Either provide <user> <email> arguments or use --file' );
77        }
78    }
79
80    /**
81     * Reset email for a single user, preserving original error behavior.
82     */
83    private function resetSingleUser( string $userName, string $email ): void {
84        $user = $this->resolveUser( $userName );
85        if ( !$user ) {
86            $this->fatalError( "Error: user '$userName' does not exist\n" );
87        }
88
89        if ( $email !== '' && !Sanitizer::validateEmail( $email ) ) {
90            $this->fatalError( "Error: email '$email' is not valid\n" );
91        }
92
93        $this->performReset( $user, $userName, $email );
94        $this->output( "Done!\n" );
95    }
96
97    /**
98     * Reset emails for users listed in a file or stdin.
99     * Each line should contain a username and email address separated by
100     * a tab character. Lines starting with "# " are treated as comments.
101     *
102     * @param string $file Path to the input file, or '-' for stdin
103     */
104    private function resetFromFile( string $file ): void {
105        $shouldClose = false;
106        if ( $file === '-' ) {
107            $handle = $this->getStdin();
108        } else {
109            if ( !is_readable( $file ) ) {
110                $this->fatalError( "Could not open file: $file" );
111            }
112            $handle = fopen( $file, 'r' );
113            if ( $handle === false ) {
114                $this->fatalError( "Could not open file: $file" );
115            }
116            $shouldClose = true;
117        }
118
119        // Pre-create system user once for email-password mode
120        $sysUser = null;
121        if ( $this->hasOption( 'email-password' ) ) {
122            $sysUser = User::newSystemUser( 'Maintenance script', [ 'steal' => true ] );
123            if ( $sysUser === null ) {
124                $this->fatalError( 'Could not create system user for email-password mode' );
125            }
126        }
127
128        $processed = 0;
129        $good = 0;
130        $bad = 0;
131        for ( $lineNum = 1; ; $lineNum++ ) {
132            $rawLine = fgets( $handle );
133            if ( $rawLine === false ) {
134                break;
135            }
136            $line = trim( $rawLine );
137            if ( $line === '' || preg_match( '/^#(\s|$)/', $line ) ) {
138                continue;
139            }
140
141            $parts = preg_split( '/\t/', $line, 2 );
142            if ( count( $parts ) !== 2 ) {
143                $this->error( "Line $lineNum: expected '<username>\\t<email>' (tab-separated), got: $line" );
144                $bad++;
145                continue;
146            }
147
148            [ $userName, $email ] = array_map( 'trim', $parts );
149            $processed++;
150
151            $user = $this->resolveUser( $userName );
152            if ( !$user ) {
153                $this->error( "Line $lineNum: user '$userName' does not exist" );
154                $bad++;
155                continue;
156            }
157
158            if ( $email !== '' && !Sanitizer::validateEmail( $email ) ) {
159                $this->error( "Line $lineNum: email '$email' is not valid" );
160                $bad++;
161                continue;
162            }
163
164            $this->performReset( $user, $userName, $email, $sysUser );
165            $this->output( "Done: $userName\n" );
166            $good++;
167
168            if ( $processed % $this->getBatchSize() === 0 ) {
169                $this->waitForReplication();
170            }
171        }
172
173        if ( $shouldClose ) {
174            fclose( $handle );
175        }
176
177        $this->output(
178            "\nBatch complete: $good succeeded, $bad failed out of $processed processed.\n"
179        );
180    }
181
182    /**
183     * Resolve a username string to a User object.
184     *
185     * @param string $userName Username or '#<id>'
186     * @return User|null The user, or null if not found
187     */
188    private function resolveUser( string $userName ): ?User {
189        $userFactory = $this->getServiceContainer()->getUserFactory();
190        if ( preg_match( '/^#\d+$/', $userName ) ) {
191            $user = $userFactory->newFromId( (int)substr( $userName, 1 ) );
192        } else {
193            $user = $userFactory->newFromName( $userName );
194        }
195        if ( !$user || !$user->isRegistered() || !$user->loadFromId() ) {
196            return null;
197        }
198        return $user;
199    }
200
201    /**
202     * Perform the actual email reset and optional password change
203     * for a given user.
204     */
205    private function performReset(
206        User $user, string $userName, string $email, ?User $sysUser = null
207    ): void {
208        $oldAddr = $user->getEmail();
209
210        // Code from https://wikitech.wikimedia.org/wiki/Password_reset
211        $user->setEmail( $email );
212        $user->setEmailAuthenticationTimestamp( wfTimestampNow() );
213        $user->saveSettings();
214
215        $logger = LoggerFactory::getInstance( 'authentication' );
216        $logger->info(
217            'Changing email address for {user} from {oldemail} to {newemail} via resetUserEmail.php', [
218                'user' => $user->getName(),
219                'oldemail' => $oldAddr,
220                'newemail' => $email,
221                'reason' => $this->getOption( 'reason', '' ),
222            ]
223        );
224
225        if ( !$this->hasOption( 'no-reset-password' ) ) {
226            // Kick whomever is currently controlling the account off if possible
227            $password = PasswordFactory::generateRandomPasswordString( 128 );
228            $status = $user->changeAuthenticationData( [
229                'username' => $user->getName(),
230                'password' => $password,
231                'retype' => $password,
232            ] );
233            if ( !$status->isGood() ) {
234                $this->error( "Password couldn't be reset for '$userName' because:" );
235                $this->error( $status );
236            } else {
237                $logger->info(
238                    'Scrambling password for {user} via resetUserEmail.php', [
239                        'user' => $user->getName(),
240                        'reason' => $this->getOption( 'reason', '' ),
241                    ]
242                );
243
244                $invalidator = $this->createChild( InvalidateUserSessions::class );
245                $invalidator->setOption( 'user', $user->getName() );
246                // Clear inherited --file option to avoid conflict with
247                // InvalidateUserSessions' own --user/--file mutual exclusion
248                $invalidator->deleteOption( 'file' );
249                $invalidator->execute();
250            }
251        }
252
253        if ( $this->hasOption( 'email-password' ) ) {
254            if ( $sysUser === null ) {
255                $sysUser = User::newSystemUser( 'Maintenance script', [ 'steal' => true ] );
256                if ( $sysUser === null ) {
257                    $this->error( "Could not create system user for email-password for '$userName'" );
258                    return;
259                }
260            }
261            $passReset = $this->getServiceContainer()->getPasswordReset();
262            $status = $passReset->execute( $sysUser, $user->getName(), $email );
263            if ( !$status->isGood() ) {
264                $this->error( "Email couldn't be sent for '$userName' because:" );
265                $this->error( $status );
266            } else {
267                $logger->info(
268                    'Password reset email sent for {user} via resetUserEmail.php', [
269                        'user' => $user->getName(),
270                        'reason' => $this->getOption( 'reason', '' ),
271                    ]
272                );
273            }
274        }
275    }
276}
277
278// @codeCoverageIgnoreStart
279$maintClass = ResetUserEmail::class;
280require_once RUN_MAINTENANCE_IF_MAIN;
281// @codeCoverageIgnoreEnd