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