Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
66.23% |
406 / 613 |
|
39.02% |
16 / 41 |
CRAP | |
0.00% |
0 / 1 |
MessageCache | |
66.34% |
406 / 612 |
|
39.02% |
16 / 41 |
1357.33 | |
0.00% |
0 / 1 |
normalizeKey | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
__construct | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
1 | |||
setLogger | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getParserOptions | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
getLocalCache | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
saveToLocalCache | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
load | |
33.33% |
4 / 12 |
|
0.00% |
0 / 1 |
16.67 | |||
loadUnguarded | |
45.07% |
32 / 71 |
|
0.00% |
0 / 1 |
86.29 | |||
loadFromDBWithMainLock | |
57.89% |
11 / 19 |
|
0.00% |
0 / 1 |
6.87 | |||
loadFromDBWithLocalLock | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
6 | |||
loadFromDB | |
78.82% |
67 / 85 |
|
0.00% |
0 / 1 |
21.08 | |||
isLanguageLoaded | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isMainCacheable | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
4 | |||
separateCacheableRows | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
4.02 | |||
replace | |
83.33% |
10 / 12 |
|
0.00% |
0 / 1 |
5.12 | |||
refreshAndReplaceInternal | |
65.00% |
26 / 40 |
|
0.00% |
0 / 1 |
12.47 | |||
isCacheExpired | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
saveToCaches | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
getValidationHash | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
3 | |||
setValidationHash | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
getReentrantScopedLock | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 | |||
get | |
82.22% |
37 / 45 |
|
0.00% |
0 / 1 |
12.81 | |||
getLanguageObject | |
16.67% |
2 / 12 |
|
0.00% |
0 / 1 |
35.36 | |||
getMessageFromFallbackChain | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
getMessageForLang | |
97.30% |
36 / 37 |
|
0.00% |
0 / 1 |
14 | |||
getMessagePageName | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getMsgFromNamespace | |
73.53% |
25 / 34 |
|
0.00% |
0 / 1 |
13.24 | |||
loadCachedMessagePageEntry | |
79.41% |
27 / 34 |
|
0.00% |
0 / 1 |
4.14 | |||
transform | |
16.67% |
2 / 12 |
|
0.00% |
0 / 1 |
8.21 | |||
getParser | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
parse | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
30 | |||
disable | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
enable | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isDisabled | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
clear | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
figureMessage | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
3.01 | |||
getAllMessageKeys | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
updateMessageOverride | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
getCheckKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getMessageTextFromContent | |
77.78% |
7 / 9 |
|
0.00% |
0 / 1 |
4.18 | |||
bigMessageCacheKey | |
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 | */ |
20 | |
21 | use MediaWiki\Config\ServiceOptions; |
22 | use MediaWiki\Context\RequestContext; |
23 | use MediaWiki\Deferred\DeferredUpdates; |
24 | use MediaWiki\Deferred\MessageCacheUpdate; |
25 | use MediaWiki\HookContainer\HookContainer; |
26 | use MediaWiki\HookContainer\HookRunner; |
27 | use MediaWiki\Languages\LanguageConverterFactory; |
28 | use MediaWiki\Languages\LanguageFactory; |
29 | use MediaWiki\Languages\LanguageFallback; |
30 | use MediaWiki\Languages\LanguageNameUtils; |
31 | use MediaWiki\Linker\LinkTarget; |
32 | use MediaWiki\Logger\LoggerFactory; |
33 | use MediaWiki\MainConfigNames; |
34 | use MediaWiki\MediaWikiServices; |
35 | use MediaWiki\Page\PageReference; |
36 | use MediaWiki\Page\PageReferenceValue; |
37 | use MediaWiki\Parser\Parser; |
38 | use MediaWiki\Parser\ParserOutput; |
39 | use MediaWiki\Revision\SlotRecord; |
40 | use MediaWiki\StubObject\StubObject; |
41 | use MediaWiki\StubObject\StubUserLang; |
42 | use MediaWiki\Title\Title; |
43 | use Psr\Log\LoggerAwareInterface; |
44 | use Psr\Log\LoggerInterface; |
45 | use Wikimedia\LightweightObjectStore\ExpirationAwareness; |
46 | use Wikimedia\ObjectCache\BagOStuff; |
47 | use Wikimedia\ObjectCache\EmptyBagOStuff; |
48 | use Wikimedia\Rdbms\Database; |
49 | use Wikimedia\Rdbms\IExpression; |
50 | use Wikimedia\Rdbms\IResultWrapper; |
51 | use Wikimedia\Rdbms\LikeValue; |
52 | use Wikimedia\RequestTimeout\TimeoutException; |
53 | use Wikimedia\ScopedCallback; |
54 | |
55 | /** |
56 | * MediaWiki message cache structure version. |
57 | * Bump this whenever the message cache format has changed. |
58 | */ |
59 | define( 'MSG_CACHE_VERSION', 2 ); |
60 | |
61 | /** |
62 | * Cache messages that are defined by MediaWiki-namespace pages or by hooks. |
63 | * |
64 | * @ingroup Language |
65 | */ |
66 | class MessageCache implements LoggerAwareInterface { |
67 | /** |
68 | * Options to be included in the ServiceOptions |
69 | */ |
70 | public const CONSTRUCTOR_OPTIONS = [ |
71 | MainConfigNames::UseDatabaseMessages, |
72 | MainConfigNames::MaxMsgCacheEntrySize, |
73 | MainConfigNames::AdaptiveMessageCache, |
74 | MainConfigNames::UseXssLanguage, |
75 | MainConfigNames::RawHtmlMessages, |
76 | ]; |
77 | |
78 | /** |
79 | * The size of the MapCacheLRU which stores message data. The maximum |
80 | * number of languages which can be efficiently loaded in a given request. |
81 | */ |
82 | public const MAX_REQUEST_LANGUAGES = 10; |
83 | |
84 | private const FOR_UPDATE = 1; // force message reload |
85 | |
86 | /** How long to wait for memcached locks */ |
87 | private const WAIT_SEC = 15; |
88 | /** How long memcached locks last */ |
89 | private const LOCK_TTL = 30; |
90 | |
91 | /** |
92 | * Lifetime for cache, for keys stored in $wanCache, in seconds. |
93 | * @var int |
94 | */ |
95 | private const WAN_TTL = ExpirationAwareness::TTL_DAY; |
96 | |
97 | /** @var LoggerInterface */ |
98 | private $logger; |
99 | |
100 | /** |
101 | * Process cache of loaded messages that are defined in MediaWiki namespace |
102 | * |
103 | * @var MapCacheLRU Map of (language code => key => " <MESSAGE>" or "!TOO BIG" or "!ERROR") |
104 | */ |
105 | private $cache; |
106 | |
107 | /** |
108 | * Map of (lowercase message key => unused) for all software-defined messages |
109 | * |
110 | * @var array |
111 | */ |
112 | private $systemMessageNames; |
113 | |
114 | /** |
115 | * @var bool[] Map of (language code => boolean) |
116 | */ |
117 | private $cacheVolatile = []; |
118 | |
119 | /** |
120 | * Should mean that database cannot be used, but check |
121 | * @var bool |
122 | */ |
123 | private $disable; |
124 | |
125 | /** @var int Maximum entry size in bytes */ |
126 | private $maxEntrySize; |
127 | |
128 | /** @var bool */ |
129 | private $adaptive; |
130 | |
131 | /** @var bool */ |
132 | private $useXssLanguage; |
133 | |
134 | /** @var string[] */ |
135 | private $rawHtmlMessages; |
136 | |
137 | /** |
138 | * Message cache has its own parser which it uses to transform messages |
139 | * @var ParserOptions |
140 | */ |
141 | private $parserOptions; |
142 | |
143 | /** @var ?Parser Lazy-created via self::getParser() */ |
144 | private $parser = null; |
145 | |
146 | /** |
147 | * @var bool |
148 | */ |
149 | private $inParser = false; |
150 | |
151 | /** @var WANObjectCache */ |
152 | private $wanCache; |
153 | /** @var BagOStuff */ |
154 | private $clusterCache; |
155 | /** @var BagOStuff */ |
156 | private $srvCache; |
157 | /** @var Language */ |
158 | private $contLang; |
159 | /** @var string */ |
160 | private $contLangCode; |
161 | /** @var ILanguageConverter */ |
162 | private $contLangConverter; |
163 | /** @var LanguageFactory */ |
164 | private $langFactory; |
165 | /** @var LocalisationCache */ |
166 | private $localisationCache; |
167 | /** @var LanguageNameUtils */ |
168 | private $languageNameUtils; |
169 | /** @var LanguageFallback */ |
170 | private $languageFallback; |
171 | /** @var HookRunner */ |
172 | private $hookRunner; |
173 | /** @var ParserFactory */ |
174 | private $parserFactory; |
175 | |
176 | /** @var (string|callable)[]|null */ |
177 | private $messageKeyOverrides; |
178 | |
179 | /** |
180 | * Normalize message key input |
181 | * |
182 | * @param string $key Input message key to be normalized |
183 | * @return string Normalized message key |
184 | */ |
185 | public static function normalizeKey( $key ) { |
186 | $lckey = strtr( $key, ' ', '_' ); |
187 | if ( $lckey === '' ) { |
188 | // T300792 |
189 | return $lckey; |
190 | } |
191 | |
192 | if ( ord( $lckey ) < 128 ) { |
193 | $lckey[0] = strtolower( $lckey[0] ); |
194 | } else { |
195 | $lckey = MediaWikiServices::getInstance()->getContentLanguage()->lcfirst( $lckey ); |
196 | } |
197 | |
198 | return $lckey; |
199 | } |
200 | |
201 | /** |
202 | * @internal For use by ServiceWiring |
203 | * @param WANObjectCache $wanCache |
204 | * @param BagOStuff $clusterCache |
205 | * @param BagOStuff $serverCache |
206 | * @param Language $contLang Content language of site |
207 | * @param LanguageConverterFactory $langConverterFactory |
208 | * @param LoggerInterface $logger |
209 | * @param ServiceOptions $options |
210 | * @param LanguageFactory $langFactory |
211 | * @param LocalisationCache $localisationCache |
212 | * @param LanguageNameUtils $languageNameUtils |
213 | * @param LanguageFallback $languageFallback |
214 | * @param HookContainer $hookContainer |
215 | * @param ParserFactory $parserFactory |
216 | */ |
217 | public function __construct( |
218 | WANObjectCache $wanCache, |
219 | BagOStuff $clusterCache, |
220 | BagOStuff $serverCache, |
221 | Language $contLang, |
222 | LanguageConverterFactory $langConverterFactory, |
223 | LoggerInterface $logger, |
224 | ServiceOptions $options, |
225 | LanguageFactory $langFactory, |
226 | LocalisationCache $localisationCache, |
227 | LanguageNameUtils $languageNameUtils, |
228 | LanguageFallback $languageFallback, |
229 | HookContainer $hookContainer, |
230 | ParserFactory $parserFactory |
231 | ) { |
232 | $this->wanCache = $wanCache; |
233 | $this->clusterCache = $clusterCache; |
234 | $this->srvCache = $serverCache; |
235 | $this->contLang = $contLang; |
236 | $this->contLangConverter = $langConverterFactory->getLanguageConverter( $contLang ); |
237 | $this->contLangCode = $contLang->getCode(); |
238 | $this->logger = $logger; |
239 | $this->langFactory = $langFactory; |
240 | $this->localisationCache = $localisationCache; |
241 | $this->languageNameUtils = $languageNameUtils; |
242 | $this->languageFallback = $languageFallback; |
243 | $this->hookRunner = new HookRunner( $hookContainer ); |
244 | $this->parserFactory = $parserFactory; |
245 | |
246 | // limit size |
247 | $this->cache = new MapCacheLRU( self::MAX_REQUEST_LANGUAGES ); |
248 | |
249 | $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
250 | $this->disable = !$options->get( MainConfigNames::UseDatabaseMessages ); |
251 | $this->maxEntrySize = $options->get( MainConfigNames::MaxMsgCacheEntrySize ); |
252 | $this->adaptive = $options->get( MainConfigNames::AdaptiveMessageCache ); |
253 | $this->useXssLanguage = $options->get( MainConfigNames::UseXssLanguage ); |
254 | $this->rawHtmlMessages = $options->get( MainConfigNames::RawHtmlMessages ); |
255 | } |
256 | |
257 | public function setLogger( LoggerInterface $logger ) { |
258 | $this->logger = $logger; |
259 | } |
260 | |
261 | /** |
262 | * ParserOptions is lazily initialised. |
263 | * |
264 | * @return ParserOptions |
265 | */ |
266 | private function getParserOptions() { |
267 | if ( !$this->parserOptions ) { |
268 | $context = RequestContext::getMain(); |
269 | $user = $context->getUser(); |
270 | if ( !$user->isSafeToLoad() ) { |
271 | // It isn't safe to use the context user yet, so don't try to get a |
272 | // ParserOptions for it. And don't cache this ParserOptions |
273 | // either. |
274 | $po = ParserOptions::newFromAnon(); |
275 | $po->setAllowUnsafeRawHtml( false ); |
276 | return $po; |
277 | } |
278 | |
279 | $this->parserOptions = ParserOptions::newFromContext( $context ); |
280 | // Messages may take parameters that could come |
281 | // from malicious sources. As a precaution, disable |
282 | // the <html> parser tag when parsing messages. |
283 | $this->parserOptions->setAllowUnsafeRawHtml( false ); |
284 | } |
285 | |
286 | return $this->parserOptions; |
287 | } |
288 | |
289 | /** |
290 | * Try to load the cache from APC. |
291 | * |
292 | * @param string $code Optional language code, see documentation of load(). |
293 | * @return array|false The cache array, or false if not in cache. |
294 | */ |
295 | private function getLocalCache( $code ) { |
296 | $cacheKey = $this->srvCache->makeKey( __CLASS__, $code ); |
297 | |
298 | return $this->srvCache->get( $cacheKey ); |
299 | } |
300 | |
301 | /** |
302 | * Save the cache to APC. |
303 | * |
304 | * @param string $code |
305 | * @param array $cache The cache array |
306 | */ |
307 | private function saveToLocalCache( $code, $cache ) { |
308 | $cacheKey = $this->srvCache->makeKey( __CLASS__, $code ); |
309 | $this->srvCache->set( $cacheKey, $cache ); |
310 | } |
311 | |
312 | /** |
313 | * Loads messages from caches or from database in this order: |
314 | * (1) local message cache (if $wgUseLocalMessageCache is enabled) |
315 | * (2) memcached |
316 | * (3) from the database. |
317 | * |
318 | * When successfully loading from (2) or (3), all higher level caches are |
319 | * updated for the newest version. |
320 | * |
321 | * Nothing is loaded if member variable mDisable is true, either manually |
322 | * set by calling code or if message loading fails (is this possible?). |
323 | * |
324 | * Returns true if cache is already populated, or it was successfully populated, |
325 | * or false if populating empty cache fails. Also returns true if MessageCache |
326 | * is disabled. |
327 | * |
328 | * @param string $code Which language to load messages for |
329 | * @param int|null $mode Use MessageCache::FOR_UPDATE to skip process cache [optional] |
330 | * @return bool |
331 | */ |
332 | private function load( string $code, $mode = null ) { |
333 | // Don't do double loading... |
334 | if ( $this->isLanguageLoaded( $code ) && $mode !== self::FOR_UPDATE ) { |
335 | return true; |
336 | } |
337 | |
338 | // Show a log message (once) if loading is disabled |
339 | if ( $this->disable ) { |
340 | static $shownDisabled = false; |
341 | if ( !$shownDisabled ) { |
342 | $this->logger->debug( __METHOD__ . ': disabled' ); |
343 | $shownDisabled = true; |
344 | } |
345 | |
346 | return true; |
347 | } |
348 | |
349 | try { |
350 | return $this->loadUnguarded( $code, $mode ); |
351 | } catch ( Throwable $e ) { |
352 | // Don't try to load again during the exception handler |
353 | $this->disable = true; |
354 | throw $e; |
355 | } |
356 | } |
357 | |
358 | /** |
359 | * Load messages from the cache or database, without exception guarding. |
360 | * |
361 | * @param string $code Which language to load messages for |
362 | * @param int|null $mode Use MessageCache::FOR_UPDATE to skip process cache [optional] |
363 | * @return bool |
364 | */ |
365 | private function loadUnguarded( $code, $mode ) { |
366 | $success = false; // Keep track of success |
367 | $staleCache = false; // a cache array with expired data, or false if none has been loaded |
368 | $where = []; // Debug info, delayed to avoid spamming debug log too much |
369 | |
370 | // A hash of the expected content is stored in a WAN cache key, providing a way |
371 | // to invalid the local cache on every server whenever a message page changes. |
372 | [ $hash, $hashVolatile ] = $this->getValidationHash( $code ); |
373 | $this->cacheVolatile[$code] = $hashVolatile; |
374 | $volatilityOnlyStaleness = false; |
375 | |
376 | // Try the local cache and check against the cluster hash key... |
377 | $cache = $this->getLocalCache( $code ); |
378 | if ( !$cache ) { |
379 | $where[] = 'local cache is empty'; |
380 | } elseif ( !isset( $cache['HASH'] ) || $cache['HASH'] !== $hash ) { |
381 | $where[] = 'local cache has the wrong hash'; |
382 | $staleCache = $cache; |
383 | } elseif ( $this->isCacheExpired( $cache ) ) { |
384 | $where[] = 'local cache is expired'; |
385 | $staleCache = $cache; |
386 | } elseif ( $hashVolatile ) { |
387 | // Some recent message page changes might not show due to DB lag |
388 | $where[] = 'local cache validation key is expired/volatile'; |
389 | $staleCache = $cache; |
390 | $volatilityOnlyStaleness = true; |
391 | } else { |
392 | $where[] = 'got from local cache'; |
393 | $this->cache->set( $code, $cache ); |
394 | $success = true; |
395 | } |
396 | |
397 | if ( !$success ) { |
398 | // Try the cluster cache, using a lock for regeneration... |
399 | $cacheKey = $this->clusterCache->makeKey( 'messages', $code ); |
400 | for ( $failedAttempts = 0; $failedAttempts <= 1; $failedAttempts++ ) { |
401 | if ( $volatilityOnlyStaleness && $staleCache ) { |
402 | // While the cluster cache *might* be more up-to-date, we do not want |
403 | // the I/O strain of every application server fetching the key here during |
404 | // the volatility period. Either this thread wins the lock and regenerates |
405 | // the cache or the stale local cache value gets reused. |
406 | $where[] = 'global cache is presumed expired'; |
407 | } else { |
408 | $cache = $this->clusterCache->get( $cacheKey ); |
409 | if ( !$cache ) { |
410 | $where[] = 'global cache is empty'; |
411 | } elseif ( $this->isCacheExpired( $cache ) ) { |
412 | $where[] = 'global cache is expired'; |
413 | $staleCache = $cache; |
414 | } elseif ( $hashVolatile ) { |
415 | // Some recent message page changes might not show due to DB lag |
416 | $where[] = 'global cache is expired/volatile'; |
417 | $staleCache = $cache; |
418 | } else { |
419 | $where[] = 'got from global cache'; |
420 | $this->cache->set( $code, $cache ); |
421 | $this->saveToCaches( $cache, 'local-only', $code ); |
422 | $success = true; |
423 | break; |
424 | } |
425 | } |
426 | |
427 | // We need to call loadFromDB(). Limit the concurrency to one thread. |
428 | // This prevents the site from going down when the cache expires. |
429 | // Note that the DB slam protection lock here is non-blocking. |
430 | $loadStatus = $this->loadFromDBWithMainLock( $code, $where, $mode ); |
431 | if ( $loadStatus === true ) { |
432 | $success = true; |
433 | break; |
434 | } elseif ( $staleCache ) { |
435 | // Use the stale cache while some other thread constructs the new one |
436 | $where[] = 'using stale cache'; |
437 | $this->cache->set( $code, $staleCache ); |
438 | $success = true; |
439 | break; |
440 | } elseif ( $failedAttempts > 0 ) { |
441 | $where[] = 'failed to find cache after waiting'; |
442 | // Already blocked once, so avoid another lock/unlock cycle. |
443 | // This case will typically be hit if memcached is down, or if |
444 | // loadFromDB() takes longer than LOCK_WAIT. |
445 | break; |
446 | } elseif ( $loadStatus === 'cantacquire' ) { |
447 | // Wait for the other thread to finish, then retry. Normally, |
448 | // the memcached get() will then yield the other thread's result. |
449 | $where[] = 'waiting for other thread to complete'; |
450 | [ , $ioError ] = $this->getReentrantScopedLock( $code ); |
451 | if ( $ioError ) { |
452 | $where[] = 'failed waiting'; |
453 | // Call loadFromDB() with concurrency limited to one thread per server. |
454 | // It should be rare for all servers to lack even a stale local cache. |
455 | $success = $this->loadFromDBWithLocalLock( $code, $where, $mode ); |
456 | break; |
457 | } |
458 | } else { |
459 | // Disable cache; $loadStatus is 'disabled' |
460 | break; |
461 | } |
462 | } |
463 | } |
464 | |
465 | if ( !$success ) { |
466 | $where[] = 'loading FAILED - cache is disabled'; |
467 | $this->disable = true; |
468 | $this->cache->set( $code, [] ); |
469 | $this->logger->error( __METHOD__ . ": Failed to load $code" ); |
470 | // This used to throw an exception, but that led to nasty side effects like |
471 | // the whole wiki being instantly down if the memcached server died |
472 | } |
473 | |
474 | if ( !$this->isLanguageLoaded( $code ) ) { |
475 | throw new LogicException( "Process cache for '$code' should be set by now." ); |
476 | } |
477 | |
478 | $info = implode( ', ', $where ); |
479 | $this->logger->debug( __METHOD__ . ": Loading $code... $info" ); |
480 | |
481 | return $success; |
482 | } |
483 | |
484 | /** |
485 | * @param string $code |
486 | * @param string[] &$where List of debug comments |
487 | * @param int|null $mode Use MessageCache::FOR_UPDATE to use DB_PRIMARY |
488 | * @return true|string One (true, "cantacquire", "disabled") |
489 | */ |
490 | private function loadFromDBWithMainLock( $code, array &$where, $mode = null ) { |
491 | // If cache updates on all levels fail, give up on message overrides. |
492 | // This is to avoid easy site outages; see $saveSuccess comments below. |
493 | $statusKey = $this->clusterCache->makeKey( 'messages', $code, 'status' ); |
494 | $status = $this->clusterCache->get( $statusKey ); |
495 | if ( $status === 'error' ) { |
496 | $where[] = "could not load; method is still globally disabled"; |
497 | return 'disabled'; |
498 | } |
499 | |
500 | // Now let's regenerate |
501 | $where[] = 'loading from DB'; |
502 | |
503 | // Lock the cache to prevent conflicting writes. |
504 | // This lock is non-blocking so stale cache can quickly be used. |
505 | // Note that load() will call a blocking getReentrantScopedLock() |
506 | // after this if it really needs to wait for any current thread. |
507 | [ $scopedLock ] = $this->getReentrantScopedLock( $code, 0 ); |
508 | if ( !$scopedLock ) { |
509 | $where[] = 'could not acquire main lock'; |
510 | return 'cantacquire'; |
511 | } |
512 | |
513 | $cache = $this->loadFromDB( $code, $mode ); |
514 | $this->cache->set( $code, $cache ); |
515 | $saveSuccess = $this->saveToCaches( $cache, 'all', $code ); |
516 | |
517 | if ( !$saveSuccess ) { |
518 | /** |
519 | * Cache save has failed. |
520 | * |
521 | * There are two main scenarios where this could be a problem: |
522 | * - The cache is more than the maximum size (typically 1MB compressed). |
523 | * - Memcached has no space remaining in the relevant slab class. This is |
524 | * unlikely with recent versions of memcached. |
525 | * |
526 | * Either way, if there is a local cache, nothing bad will happen. If there |
527 | * is no local cache, disabling the message cache for all requests avoids |
528 | * incurring a loadFromDB() overhead on every request, and thus saves the |
529 | * wiki from complete downtime under moderate traffic conditions. |
530 | */ |
531 | if ( $this->srvCache instanceof EmptyBagOStuff ) { |
532 | $this->clusterCache->set( $statusKey, 'error', 60 * 5 ); |
533 | $where[] = 'could not save cache, disabled globally for 5 minutes'; |
534 | } else { |
535 | $where[] = "could not save global cache"; |
536 | } |
537 | } |
538 | |
539 | return true; |
540 | } |
541 | |
542 | /** |
543 | * @param string $code |
544 | * @param string[] &$where List of debug comments |
545 | * @param int|null $mode Use MessageCache::FOR_UPDATE to use DB_PRIMARY |
546 | * @return bool Success |
547 | */ |
548 | private function loadFromDBWithLocalLock( $code, array &$where, $mode = null ) { |
549 | $success = false; |
550 | $where[] = 'loading from DB using local lock'; |
551 | |
552 | $scopedLock = $this->srvCache->getScopedLock( |
553 | $this->srvCache->makeKey( 'messages', $code ), |
554 | self::WAIT_SEC, |
555 | self::LOCK_TTL, |
556 | __METHOD__ |
557 | ); |
558 | if ( $scopedLock ) { |
559 | $cache = $this->loadFromDB( $code, $mode ); |
560 | $this->cache->set( $code, $cache ); |
561 | $this->saveToCaches( $cache, 'local-only', $code ); |
562 | $success = true; |
563 | } |
564 | |
565 | return $success; |
566 | } |
567 | |
568 | /** |
569 | * Loads cacheable messages from the database. Messages bigger than |
570 | * $wgMaxMsgCacheEntrySize are assigned a special value, and are loaded |
571 | * on-demand from the database later. |
572 | * |
573 | * @param string $code Language code |
574 | * @param int|null $mode Use MessageCache::FOR_UPDATE to skip process cache |
575 | * @return array Loaded messages for storing in caches |
576 | */ |
577 | private function loadFromDB( $code, $mode = null ) { |
578 | $icp = MediaWikiServices::getInstance()->getConnectionProvider(); |
579 | |
580 | $dbr = ( $mode === self::FOR_UPDATE ) ? $icp->getPrimaryDatabase() : $icp->getReplicaDatabase(); |
581 | |
582 | $cache = []; |
583 | |
584 | $mostused = []; // list of "<cased message key>/<code>" |
585 | if ( $this->adaptive && $code !== $this->contLangCode ) { |
586 | if ( !$this->cache->has( $this->contLangCode ) ) { |
587 | $this->load( $this->contLangCode ); |
588 | } |
589 | $mostused = array_keys( $this->cache->get( $this->contLangCode ) ); |
590 | foreach ( $mostused as $key => $value ) { |
591 | $mostused[$key] = "$value/$code"; |
592 | } |
593 | } |
594 | |
595 | // Common conditions |
596 | $conds = [ |
597 | 'page_is_redirect' => 0, |
598 | 'page_namespace' => NS_MEDIAWIKI, |
599 | ]; |
600 | if ( count( $mostused ) ) { |
601 | $conds['page_title'] = $mostused; |
602 | } elseif ( $code !== $this->contLangCode ) { |
603 | $conds[] = $dbr->expr( |
604 | 'page_title', |
605 | IExpression::LIKE, |
606 | new LikeValue( $dbr->anyString(), '/', $code ) |
607 | ); |
608 | } else { |
609 | // Effectively disallows use of '/' character in NS_MEDIAWIKI for uses |
610 | // other than language code. |
611 | $conds[] = $dbr->expr( |
612 | 'page_title', |
613 | IExpression::NOT_LIKE, |
614 | new LikeValue( $dbr->anyString(), '/', $dbr->anyString() ) |
615 | ); |
616 | } |
617 | |
618 | // Set the stubs for oversized software-defined messages in the main cache map |
619 | $res = $dbr->newSelectQueryBuilder() |
620 | ->select( [ 'page_title', 'page_latest' ] ) |
621 | ->from( 'page' ) |
622 | ->where( $conds ) |
623 | ->andWhere( $dbr->expr( 'page_len', '>', intval( $this->maxEntrySize ) ) ) |
624 | ->caller( __METHOD__ . "($code)-big" )->fetchResultSet(); |
625 | foreach ( $res as $row ) { |
626 | // Include entries/stubs for all keys in $mostused in adaptive mode |
627 | if ( $this->adaptive || $this->isMainCacheable( $row->page_title ) ) { |
628 | $cache[$row->page_title] = '!TOO BIG'; |
629 | } |
630 | // At least include revision ID so page changes are reflected in the hash |
631 | $cache['EXCESSIVE'][$row->page_title] = $row->page_latest; |
632 | } |
633 | |
634 | // RevisionStore cannot be injected as it would break the installer since |
635 | // it instantiates MessageCache before the DB. |
636 | $revisionStore = MediaWikiServices::getInstance()->getRevisionStore(); |
637 | // Set the text for small software-defined messages in the main cache map |
638 | $revQuery = $revisionStore->getQueryInfo( [ 'page' ] ); |
639 | |
640 | // T231196: MySQL/MariaDB (10.1.37) can sometimes irrationally decide that querying `actor` then |
641 | // `revision` then `page` is somehow better than starting with `page`. Tell it not to reorder the |
642 | // query (and also reorder it ourselves because as generated by RevisionStore it'll have |
643 | // `revision` first rather than `page`). |
644 | $revQuery['joins']['revision'] = $revQuery['joins']['page']; |
645 | unset( $revQuery['joins']['page'] ); |
646 | // It isn't actually necessary to reorder $revQuery['tables'] as Database does the right thing |
647 | // when join conditions are given for all joins, but Gergő is wary of relying on that so pull |
648 | // `page` to the start. |
649 | $revQuery['tables'] = array_merge( |
650 | [ 'page' ], |
651 | array_diff( $revQuery['tables'], [ 'page' ] ) |
652 | ); |
653 | |
654 | $res = $dbr->newSelectQueryBuilder() |
655 | ->queryInfo( $revQuery ) |
656 | ->where( $conds ) |
657 | ->andWhere( [ |
658 | $dbr->expr( 'page_len', '<=', intval( $this->maxEntrySize ) ), |
659 | 'page_latest = rev_id' // get the latest revision only |
660 | ] ) |
661 | ->caller( __METHOD__ . "($code)-small" ) |
662 | ->straightJoinOption() |
663 | ->fetchResultSet(); |
664 | |
665 | // Don't load content from uncacheable rows (T313004) |
666 | [ $cacheableRows, $uncacheableRows ] = $this->separateCacheableRows( $res ); |
667 | $result = $revisionStore->newRevisionsFromBatch( $cacheableRows, [ |
668 | 'slots' => [ SlotRecord::MAIN ], |
669 | 'content' => true |
670 | ] ); |
671 | $revisions = $result->isOK() ? $result->getValue() : []; |
672 | |
673 | foreach ( $cacheableRows as $row ) { |
674 | try { |
675 | $rev = $revisions[$row->rev_id] ?? null; |
676 | $content = $rev ? $rev->getContent( SlotRecord::MAIN ) : null; |
677 | $text = $this->getMessageTextFromContent( $content ); |
678 | } catch ( TimeoutException $e ) { |
679 | throw $e; |
680 | } catch ( Exception $ex ) { |
681 | $text = false; |
682 | } |
683 | |
684 | if ( !is_string( $text ) ) { |
685 | $entry = '!ERROR'; |
686 | $this->logger->error( |
687 | __METHOD__ |
688 | . ": failed to load message page text for {$row->page_title} ($code)" |
689 | ); |
690 | } else { |
691 | $entry = ' ' . $text; |
692 | } |
693 | $cache[$row->page_title] = $entry; |
694 | } |
695 | |
696 | foreach ( $uncacheableRows as $row ) { |
697 | // T193271: The cache object gets too big and slow to generate. |
698 | // At least include revision ID, so that page changes are reflected in the hash. |
699 | $cache['EXCESSIVE'][$row->page_title] = $row->page_latest; |
700 | } |
701 | |
702 | $cache['VERSION'] = MSG_CACHE_VERSION; |
703 | ksort( $cache ); |
704 | |
705 | // Hash for validating local cache (APC). No need to take into account |
706 | // messages larger than $wgMaxMsgCacheEntrySize, since those are only |
707 | // stored and fetched from memcache. |
708 | $cache['HASH'] = md5( serialize( $cache ) ); |
709 | $cache['EXPIRY'] = wfTimestamp( TS_MW, time() + self::WAN_TTL ); |
710 | unset( $cache['EXCESSIVE'] ); // only needed for hash |
711 | |
712 | return $cache; |
713 | } |
714 | |
715 | /** |
716 | * Whether the language was loaded and its data is still in the process cache. |
717 | * |
718 | * @param string $lang |
719 | * @return bool |
720 | */ |
721 | private function isLanguageLoaded( $lang ) { |
722 | // It is important that this only returns true if the cache was fully |
723 | // populated by load(), so that callers can assume all cache keys exist. |
724 | // It is possible for $this->cache to be only partially populated through |
725 | // methods like MessageCache::replace(), which must not make this method |
726 | // return true (T208897). And this method must cease to return true |
727 | // if the language was evicted by MapCacheLRU (T230690). |
728 | return $this->cache->hasField( $lang, 'VERSION' ); |
729 | } |
730 | |
731 | /** |
732 | * Can the given DB key be added to the main cache blob? To reduce the |
733 | * abuse impact of the MediaWiki namespace by {{int:}} and CentralNotice, |
734 | * this is only true if the page overrides a predefined message. |
735 | * |
736 | * @param string $name Message name (possibly with /code suffix) |
737 | * @param string|null $code The language code. If this is null, message |
738 | * presence will be bulk loaded for the content language. Otherwise, |
739 | * presence will be detected by loading the specified message. |
740 | * @return bool |
741 | */ |
742 | private function isMainCacheable( $name, $code = null ) { |
743 | // Convert the first letter to lowercase, and strip /code suffix |
744 | $name = $this->contLang->lcfirst( $name ); |
745 | // Include common conversion table pages. This also avoids problems with |
746 | // Installer::parse() bailing out due to disallowed DB queries (T207979). |
747 | if ( strpos( $name, 'conversiontable/' ) === 0 ) { |
748 | return true; |
749 | } |
750 | $msg = preg_replace( '/\/[a-z0-9-]{2,}$/', '', $name ); |
751 | |
752 | if ( $code === null ) { |
753 | // Bulk load |
754 | if ( $this->systemMessageNames === null ) { |
755 | $this->systemMessageNames = array_fill_keys( |
756 | $this->localisationCache->getSubitemList( $this->contLangCode, 'messages' ), |
757 | true ); |
758 | } |
759 | return isset( $this->systemMessageNames[$msg] ); |
760 | } else { |
761 | // Use individual subitem |
762 | return $this->localisationCache->getSubitem( $code, 'messages', $msg ) !== null; |
763 | } |
764 | } |
765 | |
766 | /** |
767 | * Separate cacheable from uncacheable rows in a page/revsion query result. |
768 | * |
769 | * @param IResultWrapper $res |
770 | * @return array{0:IResultWrapper|stdClass[],1:stdClass[]} An array with the cacheable |
771 | * rows in the first element and the uncacheable rows in the second. |
772 | */ |
773 | private function separateCacheableRows( $res ) { |
774 | if ( $this->adaptive ) { |
775 | // Include entries/stubs for all keys in $mostused in adaptive mode |
776 | return [ $res, [] ]; |
777 | } |
778 | $cacheableRows = []; |
779 | $uncacheableRows = []; |
780 | foreach ( $res as $row ) { |
781 | if ( $this->isMainCacheable( $row->page_title ) ) { |
782 | $cacheableRows[] = $row; |
783 | } else { |
784 | $uncacheableRows[] = $row; |
785 | } |
786 | } |
787 | return [ $cacheableRows, $uncacheableRows ]; |
788 | } |
789 | |
790 | /** |
791 | * Updates cache as necessary when message page is changed |
792 | * |
793 | * @param string $title Message cache key with the initial uppercase letter |
794 | * @param string|false $text New contents of the page (false if deleted) |
795 | */ |
796 | public function replace( $title, $text ) { |
797 | if ( $this->disable ) { |
798 | return; |
799 | } |
800 | |
801 | [ $msg, $code ] = $this->figureMessage( $title ); |
802 | if ( strpos( $title, '/' ) !== false && $code === $this->contLangCode ) { |
803 | // Content language overrides do not use the /<code> suffix |
804 | return; |
805 | } |
806 | |
807 | // (a) Update the process cache with the new message text |
808 | if ( $text === false ) { |
809 | // Page deleted |
810 | $this->cache->setField( $code, $title, '!NONEXISTENT' ); |
811 | } else { |
812 | // Ignore $wgMaxMsgCacheEntrySize so the process cache is up-to-date |
813 | $this->cache->setField( $code, $title, ' ' . $text ); |
814 | } |
815 | |
816 | // (b) Update the shared caches in a deferred update with a fresh DB snapshot |
817 | DeferredUpdates::addUpdate( |
818 | new MessageCacheUpdate( $code, $title, $msg ), |
819 | DeferredUpdates::PRESEND |
820 | ); |
821 | } |
822 | |
823 | /** |
824 | * @param string $code |
825 | * @param array[] $replacements List of (title, message key) pairs |
826 | */ |
827 | public function refreshAndReplaceInternal( string $code, array $replacements ) { |
828 | // Allow one caller at a time to avoid race conditions |
829 | [ $scopedLock ] = $this->getReentrantScopedLock( $code ); |
830 | if ( !$scopedLock ) { |
831 | foreach ( $replacements as [ $title ] ) { |
832 | $this->logger->error( |
833 | __METHOD__ . ': could not acquire lock to update {title} ({code})', |
834 | [ 'title' => $title, 'code' => $code ] ); |
835 | } |
836 | |
837 | return; |
838 | } |
839 | |
840 | // Load the existing cache to update it in the local DC cache. |
841 | // The other DCs will see a hash mismatch. |
842 | if ( $this->load( $code, self::FOR_UPDATE ) ) { |
843 | $cache = $this->cache->get( $code ); |
844 | } else { |
845 | // Err? Fall back to loading from the database. |
846 | $cache = $this->loadFromDB( $code, self::FOR_UPDATE ); |
847 | } |
848 | // Check if individual cache keys should exist and update cache accordingly |
849 | $newTextByTitle = []; // map of (title => content) |
850 | $newBigTitles = []; // map of (title => latest revision ID), like EXCESSIVE in loadFromDB() |
851 | // Can not inject the WikiPageFactory as it would break the installer since |
852 | // it instantiates MessageCache before the DB. |
853 | $wikiPageFactory = MediaWikiServices::getInstance()->getWikiPageFactory(); |
854 | foreach ( $replacements as [ $title ] ) { |
855 | $page = $wikiPageFactory->newFromTitle( Title::makeTitle( NS_MEDIAWIKI, $title ) ); |
856 | $page->loadPageData( IDBAccessObject::READ_LATEST ); |
857 | $text = $this->getMessageTextFromContent( $page->getContent() ); |
858 | // Remember the text for the blob store update later on |
859 | $newTextByTitle[$title] = $text ?? ''; |
860 | // Note that if $text is false, then $cache should have a !NONEXISTANT entry |
861 | if ( !is_string( $text ) ) { |
862 | $cache[$title] = '!NONEXISTENT'; |
863 | } elseif ( strlen( $text ) > $this->maxEntrySize ) { |
864 | $cache[$title] = '!TOO BIG'; |
865 | $newBigTitles[$title] = $page->getLatest(); |
866 | } else { |
867 | $cache[$title] = ' ' . $text; |
868 | } |
869 | } |
870 | // Update HASH for the new key. Incorporates various administrative keys, |
871 | // including the old HASH (and thereby the EXCESSIVE value from loadFromDB() |
872 | // and previous replace() calls), but that doesn't really matter since we |
873 | // only ever compare it for equality with a copy saved by saveToCaches(). |
874 | $cache['HASH'] = md5( serialize( $cache + [ 'EXCESSIVE' => $newBigTitles ] ) ); |
875 | // Update the too-big WAN cache entries now that we have the new HASH |
876 | foreach ( $newBigTitles as $title => $id ) { |
877 | // Match logic of loadCachedMessagePageEntry() |
878 | $this->wanCache->set( |
879 | $this->bigMessageCacheKey( $cache['HASH'], $title ), |
880 | ' ' . $newTextByTitle[$title], |
881 | self::WAN_TTL |
882 | ); |
883 | } |
884 | // Mark this cache as definitely being "latest" (non-volatile) so |
885 | // load() calls do not try to refresh the cache with replica DB data |
886 | $cache['LATEST'] = time(); |
887 | // Update the process cache |
888 | $this->cache->set( $code, $cache ); |
889 | // Pre-emptively update the local datacenter cache so things like edit filter and |
890 | // prevented changes are reflected immediately; these often use MediaWiki: pages. |
891 | // The datacenter handling replace() calls should be the same one handling edits |
892 | // as they require HTTP POST. |
893 | $this->saveToCaches( $cache, 'all', $code ); |
894 | // Release the lock now that the cache is saved |
895 | ScopedCallback::consume( $scopedLock ); |
896 | |
897 | // Relay the purge. Touching this check key expires cache contents |
898 | // and local cache (APC) validation hash across all datacenters. |
899 | $this->wanCache->touchCheckKey( $this->getCheckKey( $code ) ); |
900 | |
901 | // Purge the messages in the message blob store and fire any hook handlers |
902 | $blobStore = MediaWikiServices::getInstance()->getResourceLoader()->getMessageBlobStore(); |
903 | foreach ( $replacements as [ $title, $msg ] ) { |
904 | $blobStore->updateMessage( $this->contLang->lcfirst( $msg ) ); |
905 | $this->hookRunner->onMessageCacheReplace( $title, $newTextByTitle[$title] ); |
906 | } |
907 | } |
908 | |
909 | /** |
910 | * Is the given cache array expired due-to-time passing or a version change? |
911 | * |
912 | * @param array $cache |
913 | * @return bool |
914 | */ |
915 | private function isCacheExpired( $cache ) { |
916 | return !isset( $cache['VERSION'] ) || |
917 | !isset( $cache['EXPIRY'] ) || |
918 | $cache['VERSION'] !== MSG_CACHE_VERSION || |
919 | $cache['EXPIRY'] <= wfTimestampNow(); |
920 | } |
921 | |
922 | /** |
923 | * Shortcut to update caches. |
924 | * |
925 | * @param array $cache Cached messages with a version. |
926 | * @param string $dest Either "local-only" to save to local caches only |
927 | * or "all" to save to all caches. |
928 | * @param string|false $code Language code (default: false) |
929 | * @return bool |
930 | */ |
931 | private function saveToCaches( array $cache, $dest, $code = false ) { |
932 | if ( $dest === 'all' ) { |
933 | $cacheKey = $this->clusterCache->makeKey( 'messages', $code ); |
934 | $success = $this->clusterCache->set( $cacheKey, $cache ); |
935 | $this->setValidationHash( $code, $cache ); |
936 | } else { |
937 | $success = true; |
938 | } |
939 | |
940 | $this->saveToLocalCache( $code, $cache ); |
941 | |
942 | return $success; |
943 | } |
944 | |
945 | /** |
946 | * Get the md5 used to validate the local server cache |
947 | * |
948 | * @param string $code |
949 | * @return array (hash or false, bool expiry/volatility status) |
950 | */ |
951 | private function getValidationHash( $code ) { |
952 | $curTTL = null; |
953 | $value = $this->wanCache->get( |
954 | $this->wanCache->makeKey( 'messages', $code, 'hash', 'v1' ), |
955 | $curTTL, |
956 | [ $this->getCheckKey( $code ) ] |
957 | ); |
958 | |
959 | if ( $value ) { |
960 | $hash = $value['hash']; |
961 | if ( ( time() - $value['latest'] ) < WANObjectCache::TTL_MINUTE ) { |
962 | // Cache was recently updated via replace() and should be up-to-date. |
963 | // That method is only called in the primary datacenter and uses FOR_UPDATE. |
964 | $expired = false; |
965 | } else { |
966 | // See if the "check" key was bumped after the hash was generated |
967 | $expired = ( $curTTL < 0 ); |
968 | } |
969 | } else { |
970 | // No hash found at all; cache must regenerate to be safe |
971 | $hash = false; |
972 | $expired = true; |
973 | } |
974 | |
975 | return [ $hash, $expired ]; |
976 | } |
977 | |
978 | /** |
979 | * Set the md5 used to validate the local server cache |
980 | * |
981 | * If $cache has a 'LATEST' UNIX timestamp key, then the hash will not |
982 | * be treated as "volatile" by getValidationHash() for the next few seconds. |
983 | * This is triggered when $cache is generated using FOR_UPDATE mode. |
984 | * |
985 | * @param string $code |
986 | * @param array $cache Cached messages with a version |
987 | */ |
988 | private function setValidationHash( $code, array $cache ) { |
989 | $this->wanCache->set( |
990 | $this->wanCache->makeKey( 'messages', $code, 'hash', 'v1' ), |
991 | [ |
992 | 'hash' => $cache['HASH'], |
993 | 'latest' => $cache['LATEST'] ?? 0 |
994 | ], |
995 | WANObjectCache::TTL_INDEFINITE |
996 | ); |
997 | } |
998 | |
999 | /** |
1000 | * @param string $code Which language to load messages for |
1001 | * @param int $timeout Wait timeout in seconds |
1002 | * @return array (ScopedCallback or null, whether locking failed due to an I/O error) |
1003 | * @phan-return array{0:ScopedCallback|null,1:bool} |
1004 | */ |
1005 | private function getReentrantScopedLock( $code, $timeout = self::WAIT_SEC ) { |
1006 | $key = $this->clusterCache->makeKey( 'messages', $code ); |
1007 | |
1008 | $watchPoint = $this->clusterCache->watchErrors(); |
1009 | $scopedLock = $this->clusterCache->getScopedLock( |
1010 | $key, |
1011 | $timeout, |
1012 | self::LOCK_TTL, |
1013 | __METHOD__ |
1014 | ); |
1015 | $error = ( !$scopedLock && $this->clusterCache->getLastError( $watchPoint ) ); |
1016 | |
1017 | return [ $scopedLock, $error ]; |
1018 | } |
1019 | |
1020 | /** |
1021 | * Get a message from either the content language or the user language. |
1022 | * |
1023 | * First, assemble a list of languages to attempt getting the message from. This |
1024 | * chain begins with the requested language and its fallbacks and then continues with |
1025 | * the content language and its fallbacks. For each language in the chain, the following |
1026 | * process will occur (in this order): |
1027 | * 1. If a language-specific override, i.e., [[MW:msg/lang]], is available, use that. |
1028 | * Note: for the content language, there is no /lang subpage. |
1029 | * 2. Fetch from the static CDB cache. |
1030 | * 3. If available, check the database for fallback language overrides. |
1031 | * |
1032 | * This process provides a number of guarantees. When changing this code, make sure all |
1033 | * of these guarantees are preserved. |
1034 | * * If the requested language is *not* the content language, then the CDB cache for that |
1035 | * specific language will take precedence over the root database page ([[MW:msg]]). |
1036 | * * Fallbacks will be just that: fallbacks. A fallback language will never be reached if |
1037 | * the message is available *anywhere* in the language for which it is a fallback. |
1038 | * |
1039 | * @param string $key The message key |
1040 | * @param bool $useDB If true, look for the message in the DB, false |
1041 | * to use only the compiled l10n cache. |
1042 | * @param bool|string|Language|null $language Code of the language to get the message for. |
1043 | * - If string and a valid code, will create a standard language object |
1044 | * - If string but not a valid code, will create a basic language object |
1045 | * - If false, create object from the current users language |
1046 | * - If true or null, create object from the wikis content language |
1047 | * - If language object, use it as given |
1048 | * - If this parameter omitted the object from the wikis content language is used |
1049 | * - Other values than a Language object or null are deprecated. |
1050 | * @param string &$usedKey @phan-output-reference If given, will be set to the message key |
1051 | * that the message was fetched from (the requested key may be overridden by hooks). |
1052 | * |
1053 | * @return string|false False if the message doesn't exist, otherwise the |
1054 | * message (which can be empty) |
1055 | */ |
1056 | public function get( $key, $useDB = true, $language = null, &$usedKey = '' ) { |
1057 | if ( is_int( $key ) ) { |
1058 | // Fix numerical strings that somehow become ints on their way here |
1059 | $key = (string)$key; |
1060 | } elseif ( !is_string( $key ) ) { |
1061 | throw new TypeError( 'Message key must be a string' ); |
1062 | } elseif ( $key === '' ) { |
1063 | // Shortcut: the empty key is always missing |
1064 | return false; |
1065 | } |
1066 | |
1067 | $language ??= $this->contLang; |
1068 | $language = $this->getLanguageObject( $language ); |
1069 | |
1070 | // Normalise title-case input (with some inlining) |
1071 | $lckey = self::normalizeKey( $key ); |
1072 | |
1073 | // Initialize the overrides here to prevent calling the hook too early. |
1074 | if ( $this->messageKeyOverrides === null ) { |
1075 | $this->messageKeyOverrides = []; |
1076 | $this->hookRunner->onMessageCacheFetchOverrides( $this->messageKeyOverrides ); |
1077 | } |
1078 | |
1079 | if ( isset( $this->messageKeyOverrides[$lckey] ) ) { |
1080 | $override = $this->messageKeyOverrides[$lckey]; |
1081 | |
1082 | // Strings are deliberately interpreted as message keys, |
1083 | // to prevent ambiguity between message keys and functions. |
1084 | if ( is_string( $override ) ) { |
1085 | $lckey = $override; |
1086 | } else { |
1087 | $lckey = $override( $lckey, $this, $language, $useDB ); |
1088 | } |
1089 | } |
1090 | |
1091 | $this->hookRunner->onMessageCache__get( $lckey ); |
1092 | |
1093 | $usedKey = $lckey; |
1094 | |
1095 | // Loop through each language in the fallback list until we find something useful |
1096 | $message = $this->getMessageFromFallbackChain( |
1097 | $language, |
1098 | $lckey, |
1099 | !$this->disable && $useDB |
1100 | ); |
1101 | |
1102 | // If we still have no message, maybe the key was in fact a full key so try that |
1103 | if ( $message === false ) { |
1104 | $parts = explode( '/', $lckey ); |
1105 | // We may get calls for things that are http-urls from sidebar |
1106 | // Let's not load nonexistent languages for those |
1107 | // They usually have more than one slash. |
1108 | if ( count( $parts ) === 2 && $parts[1] !== '' ) { |
1109 | $message = $this->localisationCache->getSubitem( $parts[1], 'messages', $parts[0] ) ?? false; |
1110 | } |
1111 | } |
1112 | |
1113 | // Post-processing if the message exists |
1114 | if ( $message !== false ) { |
1115 | // Fix whitespace |
1116 | $message = str_replace( |
1117 | [ |
1118 | // Fix for trailing whitespace, removed by textarea |
1119 | ' ', |
1120 | // Fix for NBSP, converted to space by firefox |
1121 | ' ', |
1122 | ' ', |
1123 | '­' |
1124 | ], |
1125 | [ |
1126 | ' ', |
1127 | "\u{00A0}", |
1128 | "\u{00A0}", |
1129 | "\u{00AD}" |
1130 | ], |
1131 | $message |
1132 | ); |
1133 | } |
1134 | |
1135 | return $message; |
1136 | } |
1137 | |
1138 | /** |
1139 | * Return a Language object from $langcode |
1140 | * |
1141 | * @param Language|string|bool $langcode Either: |
1142 | * - a Language object |
1143 | * - code of the language to get the message for, if it is |
1144 | * a valid code create a language for that language, if |
1145 | * it is a string but not a valid code then make a basic |
1146 | * language object |
1147 | * - a boolean: if it's false then use the global object for |
1148 | * the current user's language (as a fallback for the old parameter |
1149 | * functionality), or if it is true then use global object |
1150 | * for the wiki's content language. |
1151 | * @return Language|StubUserLang |
1152 | */ |
1153 | private function getLanguageObject( $langcode ) { |
1154 | # Identify which language to get or create a language object for. |
1155 | # Using is_object here due to Stub objects. |
1156 | if ( is_object( $langcode ) ) { |
1157 | # Great, we already have the object (hopefully)! |
1158 | return $langcode; |
1159 | } |
1160 | |
1161 | wfDeprecated( __METHOD__ . ' with not a Language object in $langcode', '1.43' ); |
1162 | if ( $langcode === true || $langcode === $this->contLangCode ) { |
1163 | # $langcode is the language code of the wikis content language object. |
1164 | # or it is a boolean and value is true |
1165 | return $this->contLang; |
1166 | } |
1167 | |
1168 | global $wgLang; |
1169 | if ( $langcode === false || $langcode === $wgLang->getCode() ) { |
1170 | # $langcode is the language code of user language object. |
1171 | # or it was a boolean and value is false |
1172 | return $wgLang; |
1173 | } |
1174 | |
1175 | $validCodes = array_keys( $this->languageNameUtils->getLanguageNames() ); |
1176 | if ( in_array( $langcode, $validCodes ) ) { |
1177 | # $langcode corresponds to a valid language. |
1178 | return $this->langFactory->getLanguage( $langcode ); |
1179 | } |
1180 | |
1181 | # $langcode is a string, but not a valid language code; use content language. |
1182 | $this->logger->debug( 'Invalid language code passed to' . __METHOD__ . ', falling back to content language.' ); |
1183 | return $this->contLang; |
1184 | } |
1185 | |
1186 | /** |
1187 | * Given a language, try and fetch messages from that language. |
1188 | * |
1189 | * Will also consider fallbacks of that language, the site language, and fallbacks for |
1190 | * the site language. |
1191 | * |
1192 | * @see MessageCache::get |
1193 | * @param Language|StubObject $lang Preferred language |
1194 | * @param string $lckey Lowercase key for the message (as for localisation cache) |
1195 | * @param bool $useDB Whether to include messages from the wiki database |
1196 | * @return string|false The message, or false if not found |
1197 | */ |
1198 | private function getMessageFromFallbackChain( $lang, $lckey, $useDB ) { |
1199 | $alreadyTried = []; |
1200 | |
1201 | // First try the requested language. |
1202 | $message = $this->getMessageForLang( $lang, $lckey, $useDB, $alreadyTried ); |
1203 | if ( $message !== false ) { |
1204 | return $message; |
1205 | } |
1206 | |
1207 | // Now try checking the site language. |
1208 | $message = $this->getMessageForLang( $this->contLang, $lckey, $useDB, $alreadyTried ); |
1209 | return $message; |
1210 | } |
1211 | |
1212 | /** |
1213 | * Given a language, try and fetch messages from that language and its fallbacks. |
1214 | * |
1215 | * @see MessageCache::get |
1216 | * @param Language|StubObject $lang Preferred language |
1217 | * @param string $lckey Lowercase key for the message (as for localisation cache) |
1218 | * @param bool $useDB Whether to include messages from the wiki database |
1219 | * @param bool[] &$alreadyTried Contains true for each language that has been tried already |
1220 | * @return string|false The message, or false if not found |
1221 | */ |
1222 | private function getMessageForLang( $lang, $lckey, $useDB, &$alreadyTried ) { |
1223 | $langcode = $lang->getCode(); |
1224 | |
1225 | // Try checking the database for the requested language |
1226 | if ( $useDB ) { |
1227 | $uckey = $this->contLang->ucfirst( $lckey ); |
1228 | |
1229 | if ( !isset( $alreadyTried[$langcode] ) ) { |
1230 | $message = $this->getMsgFromNamespace( |
1231 | $this->getMessagePageName( $langcode, $uckey ), |
1232 | $langcode |
1233 | ); |
1234 | if ( $message !== false ) { |
1235 | return $message; |
1236 | } |
1237 | $alreadyTried[$langcode] = true; |
1238 | } |
1239 | } else { |
1240 | $uckey = null; |
1241 | } |
1242 | |
1243 | // Return a special value handled in Message::format() to display the message key |
1244 | // (and fallback keys) and the parameters passed to the message. |
1245 | // TODO: Move to a better place. |
1246 | if ( $langcode === 'qqx' ) { |
1247 | return '($*)'; |
1248 | } elseif ( |
1249 | $langcode === 'x-xss' && |
1250 | $this->useXssLanguage && |
1251 | !in_array( $lckey, $this->rawHtmlMessages, true ) |
1252 | ) { |
1253 | $xssViaInnerHtml = "<script>alert('$lckey')</script>"; |
1254 | $xssViaAttribute = '">' . $xssViaInnerHtml . '<x y="'; |
1255 | return $xssViaInnerHtml . $xssViaAttribute . '($*)'; |
1256 | } |
1257 | |
1258 | // Check the localisation cache |
1259 | [ $defaultMessage, $messageSource ] = |
1260 | $this->localisationCache->getSubitemWithSource( $langcode, 'messages', $lckey ); |
1261 | if ( $messageSource === $langcode ) { |
1262 | return $defaultMessage; |
1263 | } |
1264 | |
1265 | // Try checking the database for all of the fallback languages |
1266 | if ( $useDB ) { |
1267 | $fallbackChain = $this->languageFallback->getAll( $langcode ); |
1268 | |
1269 | foreach ( $fallbackChain as $code ) { |
1270 | if ( isset( $alreadyTried[$code] ) ) { |
1271 | continue; |
1272 | } |
1273 | |
1274 | $message = $this->getMsgFromNamespace( |
1275 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullable uckey is set when used |
1276 | $this->getMessagePageName( $code, $uckey ), $code ); |
1277 | |
1278 | if ( $message !== false ) { |
1279 | return $message; |
1280 | } |
1281 | $alreadyTried[$code] = true; |
1282 | |
1283 | // Reached the source language of the default message. Don't look for DB overrides |
1284 | // further back in the fallback chain. (T229992) |
1285 | if ( $code === $messageSource ) { |
1286 | return $defaultMessage; |
1287 | } |
1288 | } |
1289 | } |
1290 | |
1291 | return $defaultMessage ?? false; |
1292 | } |
1293 | |
1294 | /** |
1295 | * Get the message page name for a given language |
1296 | * |
1297 | * @param string $langcode |
1298 | * @param string $uckey Uppercase key for the message |
1299 | * @return string The page name |
1300 | */ |
1301 | private function getMessagePageName( $langcode, $uckey ) { |
1302 | if ( $langcode === $this->contLangCode ) { |
1303 | // Messages created in the content language will not have the /lang extension |
1304 | return $uckey; |
1305 | } else { |
1306 | return "$uckey/$langcode"; |
1307 | } |
1308 | } |
1309 | |
1310 | /** |
1311 | * Get a message from the MediaWiki namespace, with caching. The key must |
1312 | * first be converted to two-part lang/msg form if necessary. |
1313 | * |
1314 | * Unlike self::get(), this function doesn't resolve fallback chains, and |
1315 | * some callers require this behavior. LanguageConverter::parseCachedTable() |
1316 | * and self::get() are some examples in core. |
1317 | * |
1318 | * @param string $title Message cache key with the initial uppercase letter |
1319 | * @param string $code Code denoting the language to try |
1320 | * @return string|false The message, or false if it does not exist or on error |
1321 | */ |
1322 | public function getMsgFromNamespace( $title, $code ) { |
1323 | // Load all MediaWiki page definitions into cache. Note that individual keys |
1324 | // already loaded into the cache during this request remain in the cache, which |
1325 | // includes the value of hook-defined messages. |
1326 | $this->load( $code ); |
1327 | |
1328 | $entry = $this->cache->getField( $code, $title ); |
1329 | |
1330 | if ( $entry !== null ) { |
1331 | // Message page exists as an override of a software messages |
1332 | if ( substr( $entry, 0, 1 ) === ' ' ) { |
1333 | // The message exists and is not '!TOO BIG' or '!ERROR' |
1334 | return (string)substr( $entry, 1 ); |
1335 | } elseif ( $entry === '!NONEXISTENT' ) { |
1336 | // The text might be '-' or missing due to some data loss |
1337 | return false; |
1338 | } |
1339 | // Load the message page, utilizing the individual message cache. |
1340 | // If the page does not exist, there will be no hook handler fallbacks. |
1341 | $entry = $this->loadCachedMessagePageEntry( |
1342 | $title, |
1343 | $code, |
1344 | $this->cache->getField( $code, 'HASH' ) |
1345 | ); |
1346 | } else { |
1347 | // Message page either does not exist or does not override a software message |
1348 | if ( !$this->isMainCacheable( $title, $code ) ) { |
1349 | // Message page does not override any software-defined message. A custom |
1350 | // message might be defined to have content or settings specific to the wiki. |
1351 | // Load the message page, utilizing the individual message cache as needed. |
1352 | $entry = $this->loadCachedMessagePageEntry( |
1353 | $title, |
1354 | $code, |
1355 | $this->cache->getField( $code, 'HASH' ) |
1356 | ); |
1357 | } |
1358 | if ( $entry === null || substr( $entry, 0, 1 ) !== ' ' ) { |
1359 | // Message does not have a MediaWiki page definition; try hook handlers |
1360 | $message = false; |
1361 | // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args |
1362 | $this->hookRunner->onMessagesPreLoad( $title, $message, $code ); |
1363 | if ( $message !== false ) { |
1364 | $this->cache->setField( $code, $title, ' ' . $message ); |
1365 | } else { |
1366 | $this->cache->setField( $code, $title, '!NONEXISTENT' ); |
1367 | } |
1368 | |
1369 | return $message; |
1370 | } |
1371 | } |
1372 | |
1373 | if ( $entry !== false && substr( $entry, 0, 1 ) === ' ' ) { |
1374 | if ( $this->cacheVolatile[$code] ) { |
1375 | // Make sure that individual keys respect the WAN cache holdoff period too |
1376 | $this->logger->debug( |
1377 | __METHOD__ . ': loading volatile key \'{titleKey}\'', |
1378 | [ 'titleKey' => $title, 'code' => $code ] ); |
1379 | } else { |
1380 | $this->cache->setField( $code, $title, $entry ); |
1381 | } |
1382 | // The message exists, so make sure a string is returned |
1383 | return (string)substr( $entry, 1 ); |
1384 | } |
1385 | |
1386 | $this->cache->setField( $code, $title, '!NONEXISTENT' ); |
1387 | |
1388 | return false; |
1389 | } |
1390 | |
1391 | /** |
1392 | * @param string $dbKey |
1393 | * @param string $code |
1394 | * @param string $hash |
1395 | * @return string Either " <MESSAGE>" or "!NONEXISTANT" |
1396 | */ |
1397 | private function loadCachedMessagePageEntry( $dbKey, $code, $hash ) { |
1398 | $fname = __METHOD__; |
1399 | return $this->srvCache->getWithSetCallback( |
1400 | $this->srvCache->makeKey( 'messages-big', $hash, $dbKey ), |
1401 | BagOStuff::TTL_HOUR, |
1402 | function () use ( $code, $dbKey, $hash, $fname ) { |
1403 | return $this->wanCache->getWithSetCallback( |
1404 | $this->bigMessageCacheKey( $hash, $dbKey ), |
1405 | self::WAN_TTL, |
1406 | function ( $oldValue, &$ttl, &$setOpts ) use ( $dbKey, $code, $fname ) { |
1407 | // Try loading the message from the database |
1408 | $setOpts += Database::getCacheSetOptions( |
1409 | MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase() |
1410 | ); |
1411 | // Use newKnownCurrent() to avoid querying revision/user tables |
1412 | $title = Title::makeTitle( NS_MEDIAWIKI, $dbKey ); |
1413 | // Injecting RevisionStore breaks installer since it |
1414 | // instantiates MessageCache before DB. |
1415 | $revision = MediaWikiServices::getInstance() |
1416 | ->getRevisionLookup() |
1417 | ->getKnownCurrentRevision( $title ); |
1418 | if ( !$revision ) { |
1419 | // The wiki doesn't have a local override page. Cache absence with normal TTL. |
1420 | // When overrides are created, self::replace() takes care of the cache. |
1421 | return '!NONEXISTENT'; |
1422 | } |
1423 | $content = $revision->getContent( SlotRecord::MAIN ); |
1424 | if ( $content ) { |
1425 | $message = $this->getMessageTextFromContent( $content ); |
1426 | } else { |
1427 | $this->logger->warning( |
1428 | $fname . ': failed to load page text for \'{titleKey}\'', |
1429 | [ 'titleKey' => $dbKey, 'code' => $code ] |
1430 | ); |
1431 | $message = null; |
1432 | } |
1433 | |
1434 | if ( !is_string( $message ) ) { |
1435 | // Revision failed to load Content, or Content is incompatible with wikitext. |
1436 | // Possibly a temporary loading failure. |
1437 | $ttl = 5; |
1438 | |
1439 | return '!NONEXISTENT'; |
1440 | } |
1441 | |
1442 | return ' ' . $message; |
1443 | } |
1444 | ); |
1445 | } |
1446 | ); |
1447 | } |
1448 | |
1449 | /** |
1450 | * @param string $message |
1451 | * @param bool $interface |
1452 | * @param Language|null $language |
1453 | * @param PageReference|null $page |
1454 | * @return string |
1455 | */ |
1456 | public function transform( $message, $interface = false, $language = null, PageReference $page = null ) { |
1457 | // Avoid creating parser if nothing to transform |
1458 | if ( $this->inParser || !str_contains( $message, '{{' ) ) { |
1459 | return $message; |
1460 | } |
1461 | |
1462 | $parser = $this->getParser(); |
1463 | $popts = $this->getParserOptions(); |
1464 | $popts->setInterfaceMessage( $interface ); |
1465 | $popts->setTargetLanguage( $language ); |
1466 | |
1467 | $userlang = $popts->setUserLang( $language ); |
1468 | $this->inParser = true; |
1469 | $message = $parser->transformMsg( $message, $popts, $page ); |
1470 | $this->inParser = false; |
1471 | $popts->setUserLang( $userlang ); |
1472 | |
1473 | return $message; |
1474 | } |
1475 | |
1476 | /** |
1477 | * @return Parser |
1478 | */ |
1479 | public function getParser() { |
1480 | if ( !$this->parser ) { |
1481 | $this->parser = $this->parserFactory->create(); |
1482 | } |
1483 | |
1484 | return $this->parser; |
1485 | } |
1486 | |
1487 | /** |
1488 | * @param string $text |
1489 | * @param PageReference|null $page |
1490 | * @param bool $linestart Whether this is at the start of a line |
1491 | * @param bool $interface Whether this is an interface message |
1492 | * @param Language|StubUserLang|string|null $language Language code |
1493 | * @return ParserOutput|string |
1494 | */ |
1495 | public function parse( $text, PageReference $page = null, $linestart = true, |
1496 | $interface = false, $language = null |
1497 | ) { |
1498 | global $wgTitle; |
1499 | |
1500 | if ( $this->inParser ) { |
1501 | return htmlspecialchars( $text ); |
1502 | } |
1503 | |
1504 | $parser = $this->getParser(); |
1505 | $popts = $this->getParserOptions(); |
1506 | $popts->setInterfaceMessage( $interface ); |
1507 | |
1508 | if ( is_string( $language ) ) { |
1509 | $language = $this->langFactory->getLanguage( $language ); |
1510 | } |
1511 | $popts->setTargetLanguage( $language ); |
1512 | |
1513 | if ( !$page ) { |
1514 | $logger = LoggerFactory::getInstance( 'GlobalTitleFail' ); |
1515 | $logger->info( |
1516 | __METHOD__ . ' called with no title set.', |
1517 | [ 'exception' => new RuntimeException ] |
1518 | ); |
1519 | $page = $wgTitle; |
1520 | } |
1521 | // Sometimes $wgTitle isn't set either... |
1522 | if ( !$page ) { |
1523 | // It's not uncommon having a null $wgTitle in scripts. See r80898 |
1524 | // Create a ghost title in such case |
1525 | $page = PageReferenceValue::localReference( |
1526 | NS_SPECIAL, |
1527 | 'Badtitle/title not set in ' . __METHOD__ |
1528 | ); |
1529 | } |
1530 | |
1531 | $this->inParser = true; |
1532 | $res = $parser->parse( $text, $page, $popts, $linestart ); |
1533 | $this->inParser = false; |
1534 | |
1535 | return $res; |
1536 | } |
1537 | |
1538 | public function disable() { |
1539 | $this->disable = true; |
1540 | } |
1541 | |
1542 | public function enable() { |
1543 | $this->disable = false; |
1544 | } |
1545 | |
1546 | /** |
1547 | * Whether DB/cache usage is disabled for determining messages |
1548 | * |
1549 | * If so, this typically indicates either: |
1550 | * - a) load() failed to find a cached copy nor query the DB |
1551 | * - b) we are in a special context or error mode that cannot use the DB |
1552 | * If the DB is ignored, any derived HTML output or cached objects may be wrong. |
1553 | * To avoid long-term cache pollution, TTLs can be adjusted accordingly. |
1554 | * |
1555 | * @return bool |
1556 | * @since 1.27 |
1557 | */ |
1558 | public function isDisabled() { |
1559 | return $this->disable; |
1560 | } |
1561 | |
1562 | /** |
1563 | * Clear all stored messages in global and local cache |
1564 | * |
1565 | * Mainly used after a mass rebuild |
1566 | */ |
1567 | public function clear() { |
1568 | $langs = $this->languageNameUtils->getLanguageNames(); |
1569 | foreach ( $langs as $code => $_ ) { |
1570 | $this->wanCache->touchCheckKey( $this->getCheckKey( $code ) ); |
1571 | } |
1572 | $this->cache->clear(); |
1573 | } |
1574 | |
1575 | /** |
1576 | * @param string $key |
1577 | * @return array |
1578 | */ |
1579 | public function figureMessage( $key ) { |
1580 | $pieces = explode( '/', $key ); |
1581 | if ( count( $pieces ) < 2 ) { |
1582 | return [ $key, $this->contLangCode ]; |
1583 | } |
1584 | |
1585 | $lang = array_pop( $pieces ); |
1586 | if ( !$this->languageNameUtils->getLanguageName( |
1587 | $lang, |
1588 | LanguageNameUtils::AUTONYMS, |
1589 | LanguageNameUtils::DEFINED |
1590 | ) ) { |
1591 | return [ $key, $this->contLangCode ]; |
1592 | } |
1593 | |
1594 | $message = implode( '/', $pieces ); |
1595 | |
1596 | return [ $message, $lang ]; |
1597 | } |
1598 | |
1599 | /** |
1600 | * Get all message keys stored in the message cache for a given language. |
1601 | * If $code is the content language code, this will return all message keys |
1602 | * for which MediaWiki:msgkey exists. If $code is another language code, this |
1603 | * will ONLY return message keys for which MediaWiki:msgkey/$code exists. |
1604 | * |
1605 | * @param string $code Language code |
1606 | * @return string[]|null Array of message keys |
1607 | */ |
1608 | public function getAllMessageKeys( $code ) { |
1609 | $this->load( $code ); |
1610 | if ( !$this->cache->has( $code ) ) { |
1611 | // Apparently load() failed |
1612 | return null; |
1613 | } |
1614 | // Remove administrative keys |
1615 | $cache = $this->cache->get( $code ); |
1616 | unset( $cache['VERSION'] ); |
1617 | unset( $cache['EXPIRY'] ); |
1618 | unset( $cache['EXCESSIVE'] ); |
1619 | // Remove any !NONEXISTENT keys |
1620 | $cache = array_diff( $cache, [ '!NONEXISTENT' ] ); |
1621 | |
1622 | // Keys may appear with a capital first letter. lcfirst them. |
1623 | return array_map( [ $this->contLang, 'lcfirst' ], array_keys( $cache ) ); |
1624 | } |
1625 | |
1626 | /** |
1627 | * Purge message caches when a MediaWiki: page is created, updated, or deleted |
1628 | * |
1629 | * @param LinkTarget $linkTarget Message page title |
1630 | * @param Content|null $content New content for edit/create, null on deletion |
1631 | * @since 1.29 |
1632 | */ |
1633 | public function updateMessageOverride( LinkTarget $linkTarget, Content $content = null ) { |
1634 | // treat null as not existing |
1635 | $msgText = $this->getMessageTextFromContent( $content ) ?? false; |
1636 | |
1637 | $this->replace( $linkTarget->getDBkey(), $msgText ); |
1638 | |
1639 | if ( $this->contLangConverter->hasVariants() ) { |
1640 | $this->contLangConverter->updateConversionTable( $linkTarget ); |
1641 | } |
1642 | } |
1643 | |
1644 | /** |
1645 | * @param string $code Language code |
1646 | * @return string WAN cache key usable as a "check key" against language page edits |
1647 | */ |
1648 | public function getCheckKey( $code ) { |
1649 | return $this->wanCache->makeKey( 'messages', $code ); |
1650 | } |
1651 | |
1652 | /** |
1653 | * @param Content|null $content Content or null if the message page does not exist |
1654 | * @return string|false|null Returns false if $content is null and null on error |
1655 | */ |
1656 | private function getMessageTextFromContent( Content $content = null ) { |
1657 | // @TODO: could skip pseudo-messages like js/css here, based on content model |
1658 | if ( $content ) { |
1659 | // Message page exists... |
1660 | // XXX: Is this the right way to turn a Content object into a message? |
1661 | // NOTE: $content is typically either WikitextContent, JavaScriptContent or |
1662 | // CssContent. MessageContent is *not* used for storing messages, it's |
1663 | // only used for wrapping them when needed. |
1664 | $msgText = $content->getWikitextForTransclusion(); |
1665 | if ( $msgText === false || $msgText === null ) { |
1666 | // This might be due to some kind of misconfiguration... |
1667 | $msgText = null; |
1668 | $this->logger->warning( |
1669 | __METHOD__ . ": message content doesn't provide wikitext " |
1670 | . "(content model: " . $content->getModel() . ")" ); |
1671 | } |
1672 | } else { |
1673 | // Message page does not exist... |
1674 | $msgText = false; |
1675 | } |
1676 | |
1677 | return $msgText; |
1678 | } |
1679 | |
1680 | /** |
1681 | * @param string $hash Hash for this version of the entire key/value overrides map |
1682 | * @param string $title Message cache key with the initial uppercase letter |
1683 | * @return string |
1684 | */ |
1685 | private function bigMessageCacheKey( $hash, $title ) { |
1686 | return $this->wanCache->makeKey( 'messages-big', $hash, $title ); |
1687 | } |
1688 | } |