Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 166
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
BatchVanishUsers
0.00% covered (danger)
0.00%
0 / 160
0.00% covered (danger)
0.00%
0 / 6
1406
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
132
 parseUserVanishRequests
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 requestUserVanish
0.00% covered (danger)
0.00%
0 / 78
0.00% covered (danger)
0.00%
0 / 1
342
 sendVanishingSuccessfulEmail
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 msg
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21$IP = getenv( 'MW_INSTALL_PATH' );
22if ( $IP === false ) {
23    $IP = __DIR__ . '/../../..';
24}
25require_once "$IP/maintenance/Maintenance.php";
26
27use MediaWiki\Extension\CentralAuth\GlobalRename\GlobalRenameRequest;
28use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
29use MediaWiki\User\UserIdentity;
30
31class BatchVanishUsers extends Maintenance {
32
33    public function __construct() {
34        parent::__construct();
35
36        $this->requireExtension( 'CentralAuth' );
37        $this->addDescription( 'Vanish users that are in a CSV containing vanish requests.' );
38        $this->addOption( 'data', 'Path to the file containing the vanish request data.', true, true, 'd' );
39        $this->addOption( 'performer', 'Performer of the vanish action.', true, true, 'p' );
40        $this->addOption( 'output', 'Path for the generated report. (default: output.csv)', false, true, 'o' );
41        $this->addOption( 'dry-run', 'Don\'t actually vanish the users, just report what it would do.' );
42    }
43
44    public function execute(): void {
45        $csvPath = $this->getOption( 'data' );
46        $performer = $this->getOption( 'performer' );
47        $isDryRun = $this->getOption( 'dry-run' );
48        $outputPath = $this->getOption( 'output', 'output.csv' );
49
50        $services = $this->getServiceContainer();
51        if ( !$services->getCentralIdLookupFactory()->getNonLocalLookup() ) {
52            $this->fatalError( 'This script cannot be run when CentralAuth is disabled.' );
53        }
54
55        $performerUser = CentralAuthUser::getInstanceByName( $performer );
56        if ( $performerUser->getId() === 0 ) {
57            $this->fatalError( "Performer with username {$performer} cannot be found.\n" );
58        }
59        // Fetching UserIdentity from performer because GlobalRenameFactory uses both CA and UI
60        $performerIdentity = $services->getUserIdentityLookup()->getUserIdentityByName( $performer );
61        // This error should never happen, we already found the CentralAuth performer
62        if ( !$performerIdentity || !$performerIdentity->isRegistered() ) {
63            $this->fatalError( "Performed with username {$performer} cannot be found in UserIdentityLookup. \n" );
64        }
65
66        // Load and parse CSV containing vanish requests from file.
67        $handle = fopen( $csvPath, 'r' );
68        if ( !$handle ) {
69            $this->fatalError( "Unable to open vanish request data at provided path: {$csvPath}" );
70        }
71        $vanishRequests = $this->parseUserVanishRequests( $handle );
72        fclose( $handle );
73
74        $outputHandle = fopen( $outputPath, 'w' );
75        if ( !$outputHandle ) {
76            $this->fatalError( "Unable to create output file: {$outputPath}" );
77        }
78        if ( !fputcsv( $outputHandle, [ 'ticketId', 'result' ] ) ) {
79            $this->fatalError( "Unable to write to output file: {$outputPath}" );
80        }
81        $vanishRequestCount = count( $vanishRequests );
82        $successCount = 0;
83        $failureCount = 0;
84
85        // Iterate through all of the vanish requests and add them to the queue
86        // one-by-one if they're valid.
87        foreach ( $vanishRequests as $index => $request ) {
88            $current = $index + 1;
89            $messagePrefix = $isDryRun
90                ? "({$current}/{$vanishRequestCount}) (DRY RUN) "
91                : "({$current}/{$vanishRequestCount}";
92            $this->output( "{$messagePrefix}Submitting vanish request for user {$request['username']}\n" );
93
94            $requestResult = $this->requestUserVanish( $request, $performerUser, $performerIdentity );
95
96            if ( $requestResult[ 'success' ] ) {
97                $successCount++;
98            } else {
99                fputcsv( $outputHandle, [ $request[ 'ticketLink' ], $requestResult[ 'message' ] ] );
100                $failureCount++;
101            }
102        }
103
104        fclose( $outputHandle );
105
106        // Print success and failure counts.
107        $this->output( "\nSucessfully submitted {$successCount} vanish requests.\n" );
108        $this->output( "Failed to submit {$failureCount} vanish requests.\n" );
109        $this->output( "Report produced - {$outputPath}\n" );
110    }
111
112    /**
113     * Parse a CSV file containing vanish requests.
114     *
115     * @param resource $handle file stream of a CSV with vanish requests
116     * @return array an array of valid vanish requests
117     */
118    private function parseUserVanishRequests( $handle ): array {
119        $vanishRequests = [];
120
121        // Skip CSV header.
122        $data = fgets( $handle );
123        if ( $data === false ) {
124            return $vanishRequests;
125        }
126
127        do {
128            $data = fgetcsv( $handle, 4096, ',' );
129            if ( $data !== false ) {
130                $vanishRequests[] = [
131                    'createdDate' => $data[0],
132                    'ticketId' => $data[1],
133                    'ticketStatus' => $data[2],
134                    'requesterEmail' => $data[3],
135                    'ticketLink' => $data[4],
136                    'globalRenamersLink' => $data[5],
137                    'usernameLink' => $data[6],
138                    'username' => $data[7],
139                    'tags' => $data[8],
140                    'duplicateTickets' => $data[9],
141                ];
142            }
143        } while ( $data !== false );
144
145        return $vanishRequests;
146    }
147
148    /**
149     * Submit a user vanish using provided information in the request.
150     *
151     * @param array $request
152     * @param CentralAuthUser $performer
153     * @param UserIdentity $uiPerformer
154     * @return array with keys:
155     *  - "success" (bool) if the vanish action was successful
156     *  - "message" (string) detail of the operation
157     */
158    private function requestUserVanish( array $request, CentralAuthUser $performer, UserIdentity $uiPerformer ): array {
159        $isDryRun = $this->getOption( 'dry-run', false );
160
161        try {
162            $username = $request['username'];
163            $causer = CentralAuthUser::getInstanceByName( $username );
164        } catch ( InvalidArgumentException $ex ) {
165            $errorMessage = "Skipping user {$username} as that username is invalid.";
166            $this->output( $errorMessage . "\n" );
167            return [ "success" => false, "message" => "no-user" ];
168        }
169
170        if ( !$causer->exists() || !$causer->isAttached() ) {
171            $errorMessage = "Skipping user {$username} as there is no CentralAuth user with that username.";
172            $this->output( $errorMessage . "\n" );
173            return [ "success" => false, "message" => "no-user" ];
174        }
175
176        // isBlocked() is an expensive operation
177        // It is needed here and below to evaluate if the request is eligible for auto-vanish
178        // Whatever change in this also impacts the condition for auto-vanish below
179        $causerIsBlocked = $causer->isBlocked();
180        if ( $causerIsBlocked ) {
181            $errorMessage = "{$username} - has blocks.";
182            $this->output( $errorMessage . "\n" );
183            return [ "success" => false, "message" => "blocked" ];
184        }
185
186        $services = $this->getServiceContainer();
187        $globalRenameRequestStore = $services->get( 'CentralAuth.GlobalRenameRequestStore' );
188
189        if ( $globalRenameRequestStore->currentNameHasPendingRequest( $username ) ) {
190            $errorMessage = "Skipping user {$username} - there is already a pending rename or vanish request for them.";
191            $this->output( $errorMessage . "\n" );
192            return [ "success" => false, "message" => "duplicate" ];
193        }
194
195        $globalRenamersQueryParams = null;
196        $parsedLink = parse_url( $request[ 'globalRenamersLink' ] ?? '', PHP_URL_QUERY );
197        parse_str( $parsedLink, $globalRenamersQueryParams );
198        $reason = urldecode( $globalRenamersQueryParams[ 'reason' ] ?? '' );
199        $decodedNewName = urldecode( $globalRenamersQueryParams[ 'newname' ] ?? '' );
200        $newName = $decodedNewName === '' ? null : $decodedNewName;
201
202        // If new name couldn't be extracted, generate a random one
203        // Format should be `Vanished user <some_random_string>`
204        if ( !isset( $newName ) ) {
205            $attempts = 0;
206            do {
207                $candidate = wfRandomString();
208                if ( GlobalRenameRequest::isNameAvailable( $candidate )->isGood() ) {
209                    $newName = "Vanished user {$candidate}";
210                    $this->output( "New name not present in global_renamers_link. Generated '{$newName}' \n" );
211                }
212                $attempts++;
213            } while ( !isset( $newName ) && $attempts < 5 );
214        }
215
216        if ( !isset( $newName ) ) {
217            $errorMessage = "Skipping user {$username} as max attempts reached generating username.";
218            $this->output( $errorMessage . "\n" );
219            return [ "success" => false, "message" => "error" ];
220        }
221
222        $request = $globalRenameRequestStore
223            ->newBlankRequest()
224            ->setName( $username )
225            ->setNewName( $newName )
226            ->setReason( $reason )
227            ->setComments( "Added automatically by maintenance/batchVanishUsers.php" )
228            ->setPerformer( $performer->getId() )
229            ->setType( GlobalRenameRequest::VANISH );
230
231        // If request can be auto-vanished, don't add to the queue
232        // - no edits, not blocked, and no logs
233        if (
234            $causer->getGlobalEditCount() === 0 &&
235            // Commented because of lint, if causer has block(s) the function returns early (code above)
236            // $causerIsBlocked === false &&
237            !$causer->hasPublicLogs()
238        ) {
239            if ( $isDryRun ) {
240                return [ "success" => true, "message" => "dry-auto-vanished" ];
241            }
242
243            $globalRenameFactory = $services->get( 'CentralAuth.GlobalRenameFactory' );
244            $requestArray = $request->toArray();
245
246            // We need to add this two fields that are usually being provided by the Form
247            $requestArray['movepages'] = true;
248            $requestArray['suppressredirects'] = true;
249
250            $renameResult = $globalRenameFactory
251                ->newGlobalRenameUser( $uiPerformer, $causer, $newName )
252                ->rename( $requestArray );
253            if ( !$renameResult->isGood() ) {
254                $errorMessage = "Skipping user {$username} as there was a problem in the auto-vanish process.";
255                $this->output( $errorMessage . "\n" );
256                return [ "success" => false, "message" => "error" ];
257            }
258
259            // We still want to leave a record that this happened, so change
260            // the status over to 'approved' for the subsequent save.
261            $request
262                ->setPerformer( $performer->getId() )
263                ->setComments( "Your username vanish request was processed successfully." )
264                ->setStatus( GlobalRenameRequest::APPROVED );
265
266            // Save the request to the database for it to be processed later.
267            if ( !$globalRenameRequestStore->save( $request ) ) {
268                $errorMessage = "Skipping user {$username} as there was a problem in the auto-vanish process.";
269                $this->output( $errorMessage . "\n" );
270                return [ "success" => false, "message" => "error" ];
271            }
272
273            $this->sendVanishingSuccessfulEmail( $causer, $request );
274
275            return [ "success" => true, "message" => "auto-vanished" ];
276        }
277
278        // Save the vanish request to the database as all validation has
279        // passed, but only if we're not in dry run mode.
280        if ( !$isDryRun && !$globalRenameRequestStore->save( $request ) ) {
281            $errorMessage = "Skipping user {$username} as there was a problem saving the vanish request to the queue.";
282            $this->output( $errorMessage . "\n" );
283            return [ "success" => false, "message" => "error" ];
284        }
285
286        return [ "success" => true, "message" => "vanished" ];
287    }
288
289    /**
290     * Attempt to send a success email to the user whose vanish was fulfilled.
291     *
292     * TODO: https://phabricator.wikimedia.org/T369134 - refactor email sending
293     *
294     * @param CentralAuthUser $causer
295     * @param GlobalRenameRequest $request
296     * @return void
297     */
298    private function sendVanishingSuccessfulEmail( CentralAuthUser $causer, GlobalRenameRequest $request ): void {
299        $bodyKey = 'globalrenamequeue-vanish-email-body-approved-with-note';
300
301        $subject = $this->msg( 'globalrenamequeue-vanish-email-subject-approved' );
302        $body = $this->msg( $bodyKey, [ $request->getName(), $request->getComments() ] );
303
304        $from = new MailAddress(
305            $this->getConfig()->get( 'PasswordSender' ),
306            $this->msg( 'emailsender' )
307        );
308        $to = new MailAddress( $causer->getEmail(), $causer->getName(), '' );
309
310        // Users don't always have email addresses.
311        if ( !$to->address ) {
312            return;
313        }
314
315        // Attempt to send the email, and log an error if this fails.
316        $emailSendResult = UserMailer::send( $to, $from, $subject, $body );
317        if ( !$emailSendResult->isOK() ) {
318            $this->output( $emailSendResult->getValue() . "\n" );
319        }
320    }
321
322    /**
323     * Get translated messages.
324     *
325     * @param string|string[]|MessageSpecifier $key
326     * @param mixed ...$params
327     * @return string
328     */
329    private function msg( $key, ...$params ): string {
330        return wfMessage( $key, ...$params )->inLanguage( 'en' )->text();
331    }
332
333}
334
335$maintClass = BatchVanishUsers::class;
336require_once RUN_MAINTENANCE_IF_MAIN;