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