Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.37% covered (warning)
70.37%
76 / 108
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
RedirectStore
70.37% covered (warning)
70.37%
76 / 108
57.14% covered (warning)
57.14%
4 / 7
41.26
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
 getRedirectTarget
47.50% covered (danger)
47.50%
19 / 40
0.00% covered (danger)
0.00%
0 / 1
28.51
 updateRedirectTarget
80.77% covered (warning)
80.77%
42 / 52
0.00% covered (danger)
0.00%
0 / 1
6.26
 clearCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makeCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createRedirectTarget
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 truncateFragment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 * @author Derick Alangi
6 */
7
8namespace MediaWiki\Page;
9
10use MediaWiki\FileRepo\RepoGroup;
11use MediaWiki\Linker\LinkTarget;
12use MediaWiki\Title\Title;
13use MediaWiki\Title\TitleParser;
14use MediaWiki\Title\TitleValue;
15use Psr\Log\LoggerInterface;
16use Wikimedia\MapCacheLRU\MapCacheLRU;
17use Wikimedia\Rdbms\IConnectionProvider;
18
19/**
20 * Service for storing and retrieving page redirect information.
21 *
22 * @unstable
23 * @since 1.38
24 */
25class RedirectStore implements RedirectLookup {
26    private IConnectionProvider $dbProvider;
27    private PageLookup $pageLookup;
28    private TitleParser $titleParser;
29    private RepoGroup $repoGroup;
30    private LoggerInterface $logger;
31    private MapCacheLRU $procCache;
32
33    public function __construct(
34        IConnectionProvider $dbProvider,
35        PageLookup $pageLookup,
36        TitleParser $titleParser,
37        RepoGroup $repoGroup,
38        LoggerInterface $logger
39    ) {
40        $this->dbProvider = $dbProvider;
41        $this->pageLookup = $pageLookup;
42        $this->titleParser = $titleParser;
43        $this->repoGroup = $repoGroup;
44        $this->logger = $logger;
45        // Must be 500+ for QueryPage and Pager uses to be effective
46        $this->procCache = new MapCacheLRU( 1_000 );
47    }
48
49    public function getRedirectTarget( PageIdentity $page ): ?LinkTarget {
50        $cacheKey = self::makeCacheKey( $page );
51        $cachedValue = $this->procCache->get( $cacheKey );
52        if ( $cachedValue !== null ) {
53            return $cachedValue ?: null;
54        }
55
56        // Handle redirects for files included from foreign image repositories.
57        if ( $page->getNamespace() === NS_FILE ) {
58            $file = $this->repoGroup->findFile( $page );
59            if ( $file && !$file->isLocal() ) {
60                $from = $file->getRedirected();
61                $to = $file->getName();
62                if ( $from === null || $from === $to ) {
63                    $this->procCache->set( $cacheKey, false );
64                    return null;
65                }
66
67                $target = new TitleValue( NS_FILE, $to );
68                $this->procCache->set( $cacheKey, $target );
69                return $target;
70            }
71        }
72
73        $page = $this->pageLookup->getPageByReference( $page );
74        if ( $page === null || !$page->isRedirect() ) {
75            $this->procCache->set( $cacheKey, false );
76            return null;
77        }
78
79        $dbr = $this->dbProvider->getReplicaDatabase();
80        $row = $dbr->newSelectQueryBuilder()
81            ->select( [ 'rd_namespace', 'rd_title', 'rd_fragment', 'rd_interwiki' ] )
82            ->from( 'redirect' )
83            ->where( [ 'rd_from' => $page->getId() ] )
84            ->caller( __METHOD__ )
85            ->fetchRow();
86
87        if ( !$row ) {
88            $this->logger->info(
89                'Found inconsistent redirect status; probably the page was deleted after it was loaded'
90            );
91            $this->procCache->set( $cacheKey, false );
92            return null;
93        }
94
95        $target = $this->createRedirectTarget(
96            $row->rd_namespace,
97            $row->rd_title,
98            $row->rd_fragment,
99            $row->rd_interwiki
100        );
101
102        $this->procCache->set( $cacheKey, $target );
103        return $target;
104    }
105
106    /**
107     * Update the redirect target for a page.
108     *
109     * @param PageIdentity $page The page to update the redirect target for.
110     * @param LinkTarget|null $target The new redirect target, or `null` if this is not a redirect.
111     * @param bool|null $lastRevWasRedirect Whether the last revision was a redirect, or `null`
112     * if not known. If set, this allows eliding writes to the redirect table.
113     *
114     * @return bool `true` on success, `false` on failure.
115     */
116    public function updateRedirectTarget(
117        PageIdentity $page,
118        ?LinkTarget $target,
119        ?bool $lastRevWasRedirect = null
120    ) {
121        // Always update redirects (target link might have changed)
122        // Update/Insert if we don't know if the last revision was a redirect or not
123        // Delete if changing from redirect to non-redirect
124        $isRedirect = $target !== null;
125        $cacheKey = self::makeCacheKey( $page );
126
127        if ( !$isRedirect && $lastRevWasRedirect === false ) {
128            $this->procCache->set( $cacheKey, false );
129            return true;
130        }
131
132        if ( $isRedirect ) {
133            $rt = Title::newFromLinkTarget( $target );
134            if ( !$rt->isValidRedirectTarget() ) {
135                // Don't put a bad redirect into the database (T278367)
136                $this->procCache->set( $cacheKey, false );
137                return false;
138            }
139
140            $dbw = $this->dbProvider->getPrimaryDatabase();
141            $dbw->startAtomic( __METHOD__ );
142
143            $truncatedFragment = self::truncateFragment( $rt->getFragment() );
144            $dbw->newInsertQueryBuilder()
145                ->insertInto( 'redirect' )
146                ->row( [
147                    'rd_from' => $page->getId(),
148                    'rd_namespace' => $rt->getNamespace(),
149                    'rd_title' => $rt->getDBkey(),
150                    'rd_fragment' => $truncatedFragment,
151                    'rd_interwiki' => $rt->getInterwiki(),
152                ] )
153                ->onDuplicateKeyUpdate()
154                ->uniqueIndexFields( [ 'rd_from' ] )
155                ->set( [
156                    'rd_namespace' => $rt->getNamespace(),
157                    'rd_title' => $rt->getDBkey(),
158                    'rd_fragment' => $truncatedFragment,
159                    'rd_interwiki' => $rt->getInterwiki(),
160                ] )
161                ->caller( __METHOD__ )
162                ->execute();
163
164            $dbw->endAtomic( __METHOD__ );
165
166            $this->procCache->set(
167                $cacheKey,
168                $this->createRedirectTarget(
169                    $rt->getNamespace(),
170                    $rt->getDBkey(),
171                    $truncatedFragment,
172                    $rt->getInterwiki()
173                )
174            );
175        } else {
176            $dbw = $this->dbProvider->getPrimaryDatabase();
177            // This is not a redirect, remove row from redirect table
178            $dbw->newDeleteQueryBuilder()
179                ->deleteFrom( 'redirect' )
180                ->where( [ 'rd_from' => $page->getId() ] )
181                ->caller( __METHOD__ )
182                ->execute();
183
184            $this->procCache->set( $cacheKey, false );
185        }
186
187        if ( $page->getNamespace() === NS_FILE ) {
188            $this->repoGroup->getLocalRepo()->invalidateImageRedirect( $page );
189        }
190
191        return true;
192    }
193
194    /**
195     * Clear process-cached redirect information for a page.
196     *
197     * @param LinkTarget|PageIdentity $page The page to clear the cache for.
198     * @return void
199     */
200    public function clearCache( $page ) {
201        $this->procCache->clear( self::makeCacheKey( $page ) );
202    }
203
204    /**
205     * Create a process cache key for the given page.
206     * @param LinkTarget|PageIdentity $page The page to create a cache key for.
207     * @return string Cache key.
208     */
209    private static function makeCacheKey( $page ) {
210        return "{$page->getNamespace()}:{$page->getDBkey()}";
211    }
212
213    /**
214     * Create a LinkTarget appropriate for use as a redirect target.
215     *
216     * @param int $namespace The namespace of the article
217     * @param string $title Database key form
218     * @param string $fragment The link fragment (after the "#")
219     * @param string $interwiki Interwiki prefix
220     *
221     * @return LinkTarget|null `LinkTarget`, or `null` if this is not a valid redirect
222     */
223    private function createRedirectTarget( $namespace, $title, $fragment, $interwiki ): ?LinkTarget {
224        // (T203942) We can't redirect to Media namespace because it's virtual.
225        // We don't want to modify Title objects farther down the
226        // line. So, let's fix this here by changing to File namespace.
227        if ( $namespace == NS_MEDIA ) {
228            $namespace = NS_FILE;
229        }
230
231        // mimic behaviour of self::insertRedirectEntry for fragments that didn't
232        // come from the redirect table
233        $fragment = self::truncateFragment( $fragment );
234
235        // T261347: be defensive when fetching data from the redirect table.
236        // Use Title::makeTitleSafe(), and if that returns null, ignore the
237        // row. In an ideal world, the DB would be cleaned up after a
238        // namespace change, but nobody could be bothered to do that.
239        $target = $this->titleParser->makeTitleValueSafe( $namespace, $title, $fragment, $interwiki );
240        if ( $target !== null && Title::newFromLinkTarget( $target )->isValidRedirectTarget() ) {
241            return $target;
242        }
243
244        return null;
245    }
246
247    /**
248     * Truncate link fragment to maximum storable value
249     *
250     * @param string $fragment The link fragment (after the "#")
251     * @return string
252     */
253    private static function truncateFragment( $fragment ) {
254        return mb_strcut( $fragment, 0, 255 );
255    }
256}