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