Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.15% covered (success)
96.15%
175 / 182
40.00% covered (danger)
40.00%
2 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
BackfillInterwikiRightsLog
96.15% covered (success)
96.15%
175 / 182
40.00% covered (danger)
40.00%
2 / 5
32
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 execute
96.32% covered (success)
96.32%
131 / 136
0.00% covered (danger)
0.00%
0 / 1
20
 getTargetUserName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getUpToDateUserName
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 getRenames
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
5
1<?php
2/**
3 * @license GPL-2.0-or-later
4 *
5 * @file
6 * @ingroup Maintenance
7 */
8
9declare( strict_types = 1 );
10
11use MediaWiki\Logger\LoggerFactory;
12use MediaWiki\Logging\DatabaseLogEntry;
13use MediaWiki\Logging\LogEntry;
14use MediaWiki\Logging\ManualLogEntry;
15use MediaWiki\MainConfigNames;
16use MediaWiki\Maintenance\Maintenance;
17use MediaWiki\Title\Title;
18use MediaWiki\User\UserIdentityValue;
19use MediaWiki\WikiMap\WikiMap;
20use Wikimedia\Rdbms\IExpression;
21use Wikimedia\Rdbms\LikeValue;
22use Wikimedia\Rdbms\SelectQueryBuilder;
23use Wikimedia\Timestamp\ConvertibleTimestamp;
24use Wikimedia\Timestamp\TimestampFormat as TS;
25
26// @codeCoverageIgnoreStart
27require_once __DIR__ . '/Maintenance.php';
28// @codeCoverageIgnoreEnd
29
30/**
31 * Maintenance script to copy interwiki rights changes from log on the remote wiki to the current wiki
32 *
33 * @ingroup Maintenance
34 */
35class BackfillInterwikiRightsLog extends Maintenance {
36    private string $interwikiDelimiter;
37
38    public function __construct() {
39        parent::__construct();
40        $this->addDescription( 'Backfill interwiki rights log from the specified wiki' );
41        $this->addArg( 'before', 'Only interwiki rights logs before this timestamp will be processed' );
42        $this->addOption( 'remote-wiki', 'The wiki to read logs from', true, true );
43        $this->addOption( 'dry-run', 'Perform a dry run, copy nothing' );
44        $this->setBatchSize( 200 );
45    }
46
47    public function execute() {
48        $dryRun = $this->hasOption( 'dry-run' );
49        $sourceWiki = $this->getOption( 'remote-wiki' );
50        $cutoffTimestamp = ConvertibleTimestamp::convert( TS::MW, $this->getArg( 0 ) );
51
52        $currentWiki = WikiMap::getCurrentWikiId();
53        if ( $sourceWiki === $currentWiki ) {
54            $this->output( "Source wiki must be different from the current wiki.\n" );
55            return;
56        }
57
58        $sourceDb = $this->getReplicaDB( $sourceWiki );
59        $this->interwikiDelimiter = $this->getConfig()->get( MainConfigNames::UserrightsInterwikiDelimiter );
60        $titlePattern = new LikeValue( $sourceDb->anyString(), $this->interwikiDelimiter . $currentWiki );
61
62        if ( $dryRun ) {
63            $this->output( "DRY RUN: No changes will be made\n" );
64        }
65
66        $minTimestamp = $sourceDb->newSelectQueryBuilder()
67            ->select( 'log_timestamp' )
68            ->from( 'logging' )
69            ->where( [
70                'log_type' => 'rights',
71                'log_action' => 'rights',
72            ] )
73            ->orderBy( 'log_timestamp', SelectQueryBuilder::SORT_ASC )
74            ->caller( __METHOD__ )
75            ->fetchField();
76
77        if ( $minTimestamp === false ) {
78            $this->output( "No source data found, exiting\n" );
79            return;
80        }
81
82        $lastLogId = 0;
83        $lastTimestamp = $minTimestamp;
84        $count = 0;
85        $skipped = 0;
86        $minInsertedId = null;
87        $maxInsertedId = null;
88        while ( true ) {
89            $rows = DatabaseLogEntry::newSelectQueryBuilder( $sourceDb )
90                ->where( [
91                    'log_type' => 'rights',
92                    'log_action' => 'rights',
93                    $sourceDb->expr( 'log_title', IExpression::LIKE, $titlePattern ),
94                    $sourceDb->expr( 'log_timestamp', '<', $sourceDb->timestamp( $cutoffTimestamp ) ),
95                ] )
96                ->where(
97                    $sourceDb->buildComparison( '>', [
98                        'log_timestamp' => $sourceDb->timestamp( $lastTimestamp ),
99                        'log_id' => $lastLogId,
100                    ] )
101                )
102                ->orderBy( [ 'log_timestamp', 'log_id' ], SelectQueryBuilder::SORT_ASC )
103                ->limit( $this->getBatchSize() )
104                ->caller( __METHOD__ )
105                ->fetchResultSet();
106
107            if ( $rows->numRows() === 0 ) {
108                break;
109            }
110            $this->output( "Processing batch of {$rows->numRows()} log entries...\n" );
111
112            $this->beginTransactionRound( __METHOD__ );
113
114            $originalEntries = [];
115            $targetUserNames = [];
116            foreach ( $rows as $row ) {
117                $entry = DatabaseLogEntry::newFromRow( $row );
118                $originalEntries[] = $entry;
119                $targetUserNames[] = $this->getTargetUserName( $entry );
120            }
121
122            $renames = $this->getRenames( $targetUserNames );
123
124            $logsToInsert = [];
125            // For deduplication query
126            $timestampsPresent = [];
127            foreach ( $originalEntries as $originalEntry ) {
128                $lastLogId = $originalEntry->getId();
129                $lastTimestamp = $originalEntry->getTimestamp();
130
131                $targetName = $this->getTargetUserName( $originalEntry );
132                $targetNewName = $this->getUpToDateUserName( $targetName, $originalEntry->getTimestamp(), $renames );
133                if ( $targetNewName !== $targetName ) {
134                    $this->output( "Renaming $targetName to $targetNewName in entry $lastLogId\n" );
135                }
136                $targetName = $targetNewName;
137                $localTarget = Title::newFromText( $targetName, $originalEntry->getTarget()->getNamespace() );
138
139                $params = $originalEntry->getParameters();
140                if ( $originalEntry->isLegacy() ) {
141                    // We must ensure that the inserted log entry is in the current form, so that we don't create
142                    // a yet another params schema
143                    $legacyParams = $originalEntry->getParameters();
144                    if ( count( $legacyParams ) > 1 ) {
145                        $oldGroups = $legacyParams[0] === '' ? [] :
146                            array_map( 'trim', explode( ',', $legacyParams[0] ) );
147                        $newGroups = $legacyParams[1] === '' ? [] :
148                            array_map( 'trim', explode( ',', $legacyParams[1] ) );
149                        $params = [
150                            '4::oldgroups' => $oldGroups,
151                            '5::newgroups' => $newGroups,
152                        ];
153                    }
154                }
155
156                $performerName = $originalEntry->getPerformerIdentity()->getName();
157                $performer = UserIdentityValue::newExternal( $sourceWiki, $performerName );
158
159                $logEntry = new ManualLogEntry( 'rights', 'rights' );
160                $logEntry->setTimestamp( $originalEntry->getTimestamp() );
161                $logEntry->setPerformer( $performer );
162                $logEntry->setTarget( $localTarget );
163                $logEntry->setComment( $originalEntry->getComment() );
164                $logEntry->setParameters( $params );
165                $logEntry->setDeleted( $originalEntry->getDeleted() );
166                $logsToInsert[] = $logEntry;
167                $timestampsPresent[] = $logEntry->getTimestamp();
168            }
169
170            $existingRows = DatabaseLogEntry::newSelectQueryBuilder( $this->getReplicaDB() )
171                ->where( [
172                    'log_type' => 'rights',
173                    'log_action' => 'rights',
174                    'log_timestamp' => array_map(
175                        $this->getReplicaDB()->timestamp( ... ),
176                        $timestampsPresent
177                    ),
178                ] )
179                ->caller( __METHOD__ )
180                ->fetchResultSet();
181
182            // keyed by timestamp => array of target users
183            $existingChanges = [];
184            foreach ( $existingRows as $row ) {
185                $entry = DatabaseLogEntry::newFromRow( $row );
186                $existingChanges[ $entry->getTimestamp() ][] = $entry->getTarget()->getText();
187            }
188
189            foreach ( $logsToInsert as $logEntry ) {
190                // If the target user's rights were already changed at the same timestamp, skip so that we don't
191                // duplicate entries. This leaves room to false positives, where the user's rights are changed by
192                // different users at the same time. It's unlikely and we accept this risk here
193                if (
194                    isset( $existingChanges[ $logEntry->getTimestamp() ] )
195                    && in_array( $logEntry->getTarget()->getText(), $existingChanges[ $logEntry->getTimestamp() ] )
196                ) {
197                    $skipped++;
198                    continue;
199                }
200
201                if ( !$dryRun ) {
202                    $id = $logEntry->insert();
203
204                    if ( $minInsertedId === null ) {
205                        $minInsertedId = $id;
206                    }
207                    $maxInsertedId = $id;
208                }
209                $count++;
210            }
211
212            $this->commitTransactionRound( __METHOD__ );
213        }
214
215        $this->output( "Skipped $skipped log entries.\n" );
216        if ( $dryRun ) {
217            $this->output( "Would insert $count log entries.\n" );
218        } else {
219            LoggerFactory::getInstance( 'logentry' )->info(
220                'Backfilled {count} interwiki rights log entries from {sourceWiki}.',
221                [
222                    'count' => $count,
223                    'sourceWiki' => $sourceWiki,
224                    'minInsertedId' => $minInsertedId,
225                    'maxInsertedId' => $maxInsertedId,
226                ]
227            );
228
229            $minInsertedId ??= '(null)';
230            $maxInsertedId ??= '(null)';
231            $this->output( "Inserted $count log entries, with ids between $minInsertedId and $maxInsertedId.\n" );
232        }
233    }
234
235    private function getTargetUserName( LogEntry $logEntry ): string {
236        $originalTargetText = $logEntry->getTarget()->getText();
237        return explode( $this->interwikiDelimiter, $originalTargetText )[0];
238    }
239
240    private function getUpToDateUserName( string $originalName, string $timestamp, array $renames ): string {
241        while ( array_key_exists( $originalName, $renames ) ) {
242            $renameFound = false;
243            foreach ( $renames[$originalName] as $renameTimestamp => $newName ) {
244                if ( $renameTimestamp > $timestamp ) {
245                    $originalName = $newName;
246                    $timestamp = $renameTimestamp;
247                    $renameFound = true;
248                    break;
249                }
250            }
251            if ( !$renameFound ) {
252                break;
253            }
254        }
255        return $originalName;
256    }
257
258    /**
259     * Search for renames affecting the specified users
260     * @param list<string> $originalUserNames The users to resolve the renames for
261     * @return array<string,list<array{0:string,1:string}>> For each original name, a list of new names, keyed by
262     *   and ordered by the rename timestamp
263     */
264    private function getRenames( array $originalUserNames ): array {
265        $renames = [];
266        $dbr = $this->getReplicaDB();
267
268        // Convert usernames to the title form (with underscores). Use space form only in output
269        $originalUserNames = array_map( static fn ( $name ) => strtr( $name, ' ', '_' ), $originalUserNames );
270
271        while ( $originalUserNames ) {
272            $originalUserNames = array_unique( $originalUserNames );
273            $batch = array_splice( $originalUserNames, 0, 100 );
274            $renameLogs = DatabaseLogEntry::newSelectQueryBuilder( $dbr )
275                ->where( [
276                    'log_namespace' => NS_USER,
277                    'log_title' => $batch,
278                    'log_type' => 'renameuser',
279                ] )
280                ->orderBy( 'log_timestamp' )
281                ->caller( __METHOD__ )
282                ->fetchResultSet();
283
284            foreach ( $renameLogs as $renameLog ) {
285                $log = DatabaseLogEntry::newFromRow( $renameLog );
286
287                $oldName = $log->getTarget()->getDBkey();
288                $timestamp = $log->getTimestamp();
289                $params = $log->getParameters();
290                $newName = strtr( $params['5::newuser'] ?? $params[0] ?? '', ' ', '_' );
291
292                if ( $newName === '' ) {
293                    // Invalid log entry, ignore
294                    continue;
295                }
296
297                $renames[$oldName][$timestamp] = strtr( $newName, '_', ' ' );
298                if ( !array_key_exists( $newName, $renames ) ) {
299                    // Follow up on the next renames affecting the same user
300                    $originalUserNames[] = $newName;
301                }
302            }
303        }
304
305        return $renames;
306    }
307}
308
309// @codeCoverageIgnoreStart
310$maintClass = BackfillInterwikiRightsLog::class;
311require_once RUN_MAINTENANCE_IF_MAIN;
312// @codeCoverageIgnoreEnd