Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
54.24% covered (warning)
54.24%
64 / 118
40.00% covered (danger)
40.00%
2 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
DoubleRedirectJob
54.70% covered (warning)
54.70%
64 / 117
40.00% covered (danger)
40.00%
2 / 5
100.88
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 fixRedirects
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
42
 run
61.70% covered (warning)
61.70%
29 / 47
0.00% covered (danger)
0.00%
0 / 1
20.09
 getFinalDestination
88.89% covered (warning)
88.89%
24 / 27
0.00% covered (danger)
0.00%
0 / 1
5.03
 getUser
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\JobQueue\Jobs;
8
9use MediaWiki\Cache\CacheKeyHelper;
10use MediaWiki\JobQueue\Job;
11use MediaWiki\Linker\LinkTarget;
12use MediaWiki\MediaWikiServices;
13use MediaWiki\Page\PageReference;
14use MediaWiki\Page\WikiPageFactory;
15use MediaWiki\Parser\MagicWordFactory;
16use MediaWiki\Revision\RevisionLookup;
17use MediaWiki\Revision\SlotRecord;
18use MediaWiki\Title\Title;
19use MediaWiki\User\User;
20use Wikimedia\Rdbms\IDBAccessObject;
21
22/**
23 * Fix any double redirects after moving a page.
24 *
25 * @ingroup JobQueue
26 */
27class DoubleRedirectJob extends Job {
28    /**
29     * @var int Max number of double redirect jobs counter.
30     *   This is meant to avoid excessive memory usage. This is
31     *   also used in fixDoubleRedirects.php script.
32     */
33    public const MAX_DR_JOBS_COUNTER = 10000;
34
35    /** @var Title The title which has changed, redirects pointing to this
36     *    title are fixed
37     */
38    private $redirTitle;
39
40    /** @var User */
41    private static $user;
42
43    /** @var RevisionLookup */
44    private $revisionLookup;
45
46    /** @var MagicWordFactory */
47    private $magicWordFactory;
48
49    /** @var WikiPageFactory */
50    private $wikiPageFactory;
51
52    /**
53     * @param PageReference $page
54     * @param array $params Expected to contain these elements:
55     * - 'redirTitle' => string The title that changed and should be fixed.
56     * - 'reason' => string Reason for the change, can be "move" or "maintenance". Used as a suffix
57     *   for the message keys "double-redirect-fixed-move" and
58     *   "double-redirect-fixed-maintenance".
59     * ]
60     * @param RevisionLookup $revisionLookup
61     * @param MagicWordFactory $magicWordFactory
62     * @param WikiPageFactory $wikiPageFactory
63     */
64    public function __construct(
65        PageReference $page,
66        array $params,
67        RevisionLookup $revisionLookup,
68        MagicWordFactory $magicWordFactory,
69        WikiPageFactory $wikiPageFactory
70    ) {
71        parent::__construct( 'fixDoubleRedirect', $page, $params );
72        $this->redirTitle = Title::newFromText( $params['redirTitle'] );
73        $this->revisionLookup = $revisionLookup;
74        $this->magicWordFactory = $magicWordFactory;
75        $this->wikiPageFactory = $wikiPageFactory;
76    }
77
78    /**
79     * Insert jobs into the job queue to fix redirects to the given title
80     * @param string $reason The reason for the fix, see message
81     *   "double-redirect-fixed-<reason>"
82     * @param LinkTarget $redirTitle The title which has changed, redirects
83     *   pointing to this title are fixed
84     */
85    public static function fixRedirects( $reason, $redirTitle ) {
86        # Need to use the primary DB to get the redirect table updated in the same transaction
87        $services = MediaWikiServices::getInstance();
88        $dbw = $services->getConnectionProvider()->getPrimaryDatabase();
89        $res = $dbw->newSelectQueryBuilder()
90            ->select( [ 'page_namespace', 'page_title' ] )
91            ->from( 'redirect' )
92            ->join( 'page', null, 'page_id = rd_from' )
93            ->where( [ 'rd_namespace' => $redirTitle->getNamespace(), 'rd_title' => $redirTitle->getDBkey() ] )
94            ->andWhere( [ 'rd_interwiki' => '' ] )
95            ->caller( __METHOD__ )->fetchResultSet();
96        if ( !$res->numRows() ) {
97            return;
98        }
99        $jobs = [];
100        $jobQueueGroup = $services->getJobQueueGroup();
101        foreach ( $res as $row ) {
102            $title = Title::makeTitleSafe( $row->page_namespace, $row->page_title );
103            if ( !$title || !$title->canExist() ) {
104                continue;
105            }
106
107            $jobs[] = new self(
108                $title,
109                [
110                    'reason' => $reason,
111                    'redirTitle' => $services->getTitleFormatter()
112                        ->getPrefixedDBkey( $redirTitle )
113                ],
114                $services->getRevisionLookup(),
115                $services->getMagicWordFactory(),
116                $services->getWikiPageFactory()
117            );
118            # Avoid excessive memory usage
119            if ( count( $jobs ) > self::MAX_DR_JOBS_COUNTER ) {
120                $jobQueueGroup->push( $jobs );
121                $jobs = [];
122            }
123        }
124        $jobQueueGroup->push( $jobs );
125    }
126
127    /**
128     * @return bool
129     */
130    public function run() {
131        if ( !$this->redirTitle ) {
132            $this->setLastError( 'Invalid title' );
133
134            return false;
135        }
136
137        if ( !$this->title->canExist() ) {
138            // Needs a proper title for WikiPageFactory::newFromTitle and RevisionStore::getRevisionByTitle
139            $this->setLastError( 'Cannot edit title' );
140
141            return false;
142        }
143
144        $targetRev = $this->revisionLookup
145            ->getRevisionByTitle( $this->title, 0, IDBAccessObject::READ_LATEST );
146        if ( !$targetRev ) {
147            wfDebug( __METHOD__ . ": target redirect already deleted, ignoring" );
148
149            return true;
150        }
151        $content = $targetRev->getContent( SlotRecord::MAIN );
152        $currentDest = $content ? $content->getRedirectTarget() : null;
153        if ( !$currentDest || !$currentDest->equals( $this->redirTitle ) ) {
154            wfDebug( __METHOD__ . ": Redirect has changed since the job was queued" );
155
156            return true;
157        }
158
159        // Check for a suppression tag (used e.g. in periodically archived discussions)
160        $mw = $this->magicWordFactory->get( 'staticredirect' );
161        if ( $content->matchMagicWord( $mw ) ) {
162            wfDebug( __METHOD__ . ": skipping: suppressed with __STATICREDIRECT__" );
163
164            return true;
165        }
166
167        // Find the current final destination
168        $newTitle = self::getFinalDestination( $this->redirTitle );
169        if ( !$newTitle ) {
170            wfDebug( __METHOD__ .
171                ": skipping: single redirect, circular redirect or invalid redirect destination" );
172
173            return true;
174        }
175        if ( $newTitle->equals( $this->redirTitle ) ) {
176            // The redirect is already right, no need to change it
177            // This can happen if the page was moved back (say after vandalism)
178            wfDebug( __METHOD__ . " : skipping, already good" );
179        }
180
181        // Preserve fragment (T16904)
182        $newTitle = Title::makeTitle( $newTitle->getNamespace(), $newTitle->getDBkey(),
183            $currentDest->getFragment(), $newTitle->getInterwiki() );
184
185        // Fix the text
186        $newContent = $content->updateRedirect( $newTitle );
187
188        if ( $newContent->equals( $content ) ) {
189            $this->setLastError( 'Content unchanged???' );
190
191            return false;
192        }
193
194        $user = $this->getUser();
195        if ( !$user ) {
196            $this->setLastError( 'Invalid user' );
197
198            return false;
199        }
200
201        // Save it
202        // phpcs:ignore MediaWiki.Usage.DeprecatedGlobalVariables.Deprecated$wgUser
203        global $wgUser;
204        $oldUser = $wgUser;
205        $wgUser = $user;
206        $article = $this->wikiPageFactory->newFromTitle( $this->title );
207
208        // Messages: double-redirect-fixed-move, double-redirect-fixed-maintenance
209        $reason = wfMessage( 'double-redirect-fixed-' . $this->params['reason'],
210            $this->redirTitle->getPrefixedText(), $newTitle->getPrefixedText()
211        )->inContentLanguage()->text();
212        // Avoid RC flood, and use minor to avoid email notifs
213        $flags = EDIT_UPDATE | EDIT_SUPPRESS_RC | EDIT_INTERNAL | EDIT_MINOR;
214        $article->doUserEditContent( $newContent, $user, $reason, $flags );
215        $wgUser = $oldUser;
216
217        return true;
218    }
219
220    /**
221     * Get the final destination of a redirect
222     *
223     * @param LinkTarget $title
224     *
225     * @return Title|false The final Title after following all redirects, or false if
226     *  the page is not a redirect or the redirect loops.
227     */
228    public static function getFinalDestination( $title ) {
229        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
230
231        // Circular redirect check
232        $seenTitles = [];
233        $dest = false;
234
235        while ( true ) {
236            $titleText = CacheKeyHelper::getKeyForPage( $title );
237            if ( isset( $seenTitles[$titleText] ) ) {
238                wfDebug( __METHOD__, "Circular redirect detected, aborting" );
239
240                return false;
241            }
242            $seenTitles[$titleText] = true;
243
244            if ( $title->isExternal() ) {
245                // If the target is interwiki, we have to break early (T42352).
246                // Otherwise it will look up a row in the local page table
247                // with the namespace/page of the interwiki target which can cause
248                // unexpected results (e.g. X -> foo:Bar -> Bar -> .. )
249                break;
250            }
251            $row = $dbw->newSelectQueryBuilder()
252                ->select( [ 'rd_namespace', 'rd_title', 'rd_interwiki' ] )
253                ->from( 'redirect' )
254                ->join( 'page', null, 'page_id = rd_from' )
255                ->where( [ 'page_namespace' => $title->getNamespace() ] )
256                ->andWhere( [ 'page_title' => $title->getDBkey() ] )
257                ->caller( __METHOD__ )->fetchRow();
258            if ( !$row ) {
259                # No redirect from here, chain terminates
260                break;
261            } else {
262                $dest = $title = Title::makeTitle(
263                    $row->rd_namespace,
264                    $row->rd_title,
265                    '',
266                    $row->rd_interwiki
267                );
268            }
269        }
270
271        return $dest;
272    }
273
274    /**
275     * Get a user object for doing edits, from a request-lifetime cache
276     * False will be returned if the user name specified in the
277     * 'double-redirect-fixer' message is invalid.
278     *
279     * @return User|false
280     */
281    private function getUser() {
282        if ( !self::$user ) {
283            $username = wfMessage( 'double-redirect-fixer' )->inContentLanguage()->text();
284            self::$user = User::newFromName( $username );
285            # User::newFromName() can return false on a badly configured wiki.
286            if ( self::$user && !self::$user->isRegistered() ) {
287                self::$user->addToDatabase();
288            }
289        }
290
291        return self::$user;
292    }
293}
294
295/** @deprecated class alias since 1.44 */
296class_alias( DoubleRedirectJob::class, 'DoubleRedirectJob' );