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 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | * @author Derick Alangi |
| 6 | */ |
| 7 | |
| 8 | namespace MediaWiki\Page; |
| 9 | |
| 10 | use MediaWiki\FileRepo\RepoGroup; |
| 11 | use MediaWiki\Linker\LinkTarget; |
| 12 | use MediaWiki\Title\Title; |
| 13 | use MediaWiki\Title\TitleParser; |
| 14 | use MediaWiki\Title\TitleValue; |
| 15 | use Psr\Log\LoggerInterface; |
| 16 | use Wikimedia\MapCacheLRU\MapCacheLRU; |
| 17 | use Wikimedia\Rdbms\IConnectionProvider; |
| 18 | |
| 19 | /** |
| 20 | * Service for storing and retrieving page redirect information. |
| 21 | * |
| 22 | * @unstable |
| 23 | * @since 1.38 |
| 24 | */ |
| 25 | class 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 | } |