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