Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.28% covered (success)
90.28%
288 / 319
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
LazyVariableComputer
90.28% covered (success)
90.28%
288 / 319
50.00% covered (danger)
50.00%
3 / 6
80.16
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 compute
88.84% covered (warning)
88.84%
223 / 251
0.00% covered (danger)
0.00%
0 / 1
69.69
 getLinksFromDB
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getLastPageAuthors
93.94% covered (success)
93.94%
31 / 33
0.00% covered (danger)
0.00%
0 / 1
4.00
 getContentModelFromRevision
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 parseNonEditWikitext
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\Variables;
4
5use MediaWiki\Content\ContentHandler;
6use MediaWiki\Content\TextContent;
7use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
8use MediaWiki\Extension\AbuseFilter\Parser\AFPData;
9use MediaWiki\Extension\AbuseFilter\TextExtractor;
10use MediaWiki\ExternalLinks\ExternalLinksLookup;
11use MediaWiki\ExternalLinks\LinkFilter;
12use MediaWiki\Language\Language;
13use MediaWiki\Parser\ParserFactory;
14use MediaWiki\Parser\ParserOptions;
15use MediaWiki\Permissions\PermissionManager;
16use MediaWiki\Permissions\RestrictionStore;
17use MediaWiki\Revision\RevisionLookup;
18use MediaWiki\Revision\RevisionRecord;
19use MediaWiki\Revision\RevisionStore;
20use MediaWiki\Revision\SlotRecord;
21use MediaWiki\Storage\PreparedUpdate;
22use MediaWiki\Title\Title;
23use MediaWiki\User\ExternalUserNames;
24use MediaWiki\User\User;
25use MediaWiki\User\UserEditTracker;
26use MediaWiki\User\UserGroupManager;
27use MediaWiki\User\UserIdentity;
28use MediaWiki\User\UserIdentityUtils;
29use Psr\Log\LoggerInterface;
30use stdClass;
31use StringUtils;
32use UnexpectedValueException;
33use Wikimedia\Diff\Diff;
34use Wikimedia\Diff\UnifiedDiffFormatter;
35use Wikimedia\IPUtils;
36use Wikimedia\ObjectCache\WANObjectCache;
37use Wikimedia\Rdbms\Database;
38use Wikimedia\Rdbms\LBFactory;
39use Wikimedia\Rdbms\SelectQueryBuilder;
40use WikiPage;
41
42/**
43 * Service used to compute lazy-loaded variable.
44 * @internal
45 */
46class LazyVariableComputer {
47    public const SERVICE_NAME = 'AbuseFilterLazyVariableComputer';
48
49    /**
50     * @var float The amount of time to subtract from profiling
51     * @todo This is a hack
52     */
53    public static $profilingExtraTime = 0;
54
55    /** @var TextExtractor */
56    private $textExtractor;
57
58    /** @var AbuseFilterHookRunner */
59    private $hookRunner;
60
61    /** @var LoggerInterface */
62    private $logger;
63
64    /** @var LBFactory */
65    private $lbFactory;
66
67    /** @var WANObjectCache */
68    private $wanCache;
69
70    /** @var RevisionLookup */
71    private $revisionLookup;
72
73    /** @var RevisionStore */
74    private $revisionStore;
75
76    /** @var Language */
77    private $contentLanguage;
78
79    /** @var ParserFactory */
80    private $parserFactory;
81
82    /** @var UserEditTracker */
83    private $userEditTracker;
84
85    /** @var UserGroupManager */
86    private $userGroupManager;
87
88    /** @var PermissionManager */
89    private $permissionManager;
90
91    /** @var RestrictionStore */
92    private $restrictionStore;
93
94    /** @var UserIdentityUtils */
95    private $userIdentityUtils;
96
97    /** @var string */
98    private $wikiID;
99
100    /**
101     * @param TextExtractor $textExtractor
102     * @param AbuseFilterHookRunner $hookRunner
103     * @param LoggerInterface $logger
104     * @param LBFactory $lbFactory
105     * @param WANObjectCache $wanCache
106     * @param RevisionLookup $revisionLookup
107     * @param RevisionStore $revisionStore
108     * @param Language $contentLanguage
109     * @param ParserFactory $parserFactory
110     * @param UserEditTracker $userEditTracker
111     * @param UserGroupManager $userGroupManager
112     * @param PermissionManager $permissionManager
113     * @param RestrictionStore $restrictionStore
114     * @param UserIdentityUtils $userIdentityUtils
115     * @param string $wikiID
116     */
117    public function __construct(
118        TextExtractor $textExtractor,
119        AbuseFilterHookRunner $hookRunner,
120        LoggerInterface $logger,
121        LBFactory $lbFactory,
122        WANObjectCache $wanCache,
123        RevisionLookup $revisionLookup,
124        RevisionStore $revisionStore,
125        Language $contentLanguage,
126        ParserFactory $parserFactory,
127        UserEditTracker $userEditTracker,
128        UserGroupManager $userGroupManager,
129        PermissionManager $permissionManager,
130        RestrictionStore $restrictionStore,
131        UserIdentityUtils $userIdentityUtils,
132        string $wikiID
133    ) {
134        $this->textExtractor = $textExtractor;
135        $this->hookRunner = $hookRunner;
136        $this->logger = $logger;
137        $this->lbFactory = $lbFactory;
138        $this->wanCache = $wanCache;
139        $this->revisionLookup = $revisionLookup;
140        $this->revisionStore = $revisionStore;
141        $this->contentLanguage = $contentLanguage;
142        $this->parserFactory = $parserFactory;
143        $this->userEditTracker = $userEditTracker;
144        $this->userGroupManager = $userGroupManager;
145        $this->permissionManager = $permissionManager;
146        $this->restrictionStore = $restrictionStore;
147        $this->userIdentityUtils = $userIdentityUtils;
148        $this->wikiID = $wikiID;
149    }
150
151    /**
152     * XXX: $getVarCB is a hack to hide the cyclic dependency with VariablesManager. See T261069 for possible
153     * solutions. This might also be merged into VariablesManager, but it would bring a ton of dependencies.
154     * @todo Should we remove $vars parameter (check hooks)?
155     *
156     * @param LazyLoadedVariable $var
157     * @param VariableHolder $vars
158     * @param callable $getVarCB
159     * @phan-param callable(string $name):AFPData $getVarCB
160     * @return AFPData
161     */
162    public function compute( LazyLoadedVariable $var, VariableHolder $vars, callable $getVarCB ) {
163        $parameters = $var->getParameters();
164        $varMethod = $var->getMethod();
165        $result = null;
166
167        if ( !$this->hookRunner->onAbuseFilter_interceptVariable(
168            $varMethod,
169            $vars,
170            $parameters,
171            $result
172        ) ) {
173            return $result instanceof AFPData
174                ? $result : AFPData::newFromPHPVar( $result );
175        }
176
177        switch ( $varMethod ) {
178            case 'diff':
179                $text1Var = $parameters['oldtext-var'];
180                $text2Var = $parameters['newtext-var'];
181                $text1 = $getVarCB( $text1Var )->toString();
182                $text2 = $getVarCB( $text2Var )->toString();
183                // T74329: if there's no text, don't return an array with the empty string
184                $text1 = $text1 === '' ? [] : explode( "\n", $text1 );
185                $text2 = $text2 === '' ? [] : explode( "\n", $text2 );
186                $diffs = new Diff( $text1, $text2 );
187                $format = new UnifiedDiffFormatter();
188                $result = $format->format( $diffs );
189                break;
190            case 'diff-split':
191                $diff = $getVarCB( $parameters['diff-var'] )->toString();
192                $line_prefix = $parameters['line-prefix'];
193                $diff_lines = explode( "\n", $diff );
194                $result = [];
195                foreach ( $diff_lines as $line ) {
196                    if ( ( $line[0] ?? '' ) === $line_prefix ) {
197                        $result[] = substr( $line, 1 );
198                    }
199                }
200                break;
201            case 'array-diff':
202                $baseVar = $parameters['base-var'];
203                $minusVar = $parameters['minus-var'];
204
205                $baseArray = $getVarCB( $baseVar )->toNative();
206                $minusArray = $getVarCB( $minusVar )->toNative();
207
208                $result = array_diff( $baseArray, $minusArray );
209                break;
210            case 'links-from-wikitext':
211                // This should ONLY be used when sharing a parse operation with the edit.
212
213                /** @var WikiPage $article */
214                $article = $parameters['article'];
215                if ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) {
216                    // Shared with the edit, don't count it in profiling
217                    $startTime = microtime( true );
218                    $textVar = $parameters['text-var'];
219
220                    $new_text = $getVarCB( $textVar )->toString();
221                    $content = ContentHandler::makeContent( $new_text, $article->getTitle() );
222                    $editInfo = $article->prepareContentForEdit(
223                        $content,
224                        null,
225                        $parameters['contextUserIdentity']
226                    );
227                    $result = LinkFilter::getIndexedUrlsNonReversed(
228                        array_keys( $editInfo->output->getExternalLinks() )
229                    );
230                    self::$profilingExtraTime += ( microtime( true ) - $startTime );
231                    break;
232                }
233            // Otherwise fall back to database
234            case 'links-from-wikitext-or-database':
235                // TODO: use Content object instead, if available!
236                /** @var WikiPage $article */
237                $article ??= $parameters['article'];
238
239                // this inference is ugly, but the name isn't accessible from here
240                // and we only want this for debugging
241                $textVar = $parameters['text-var'];
242                $varName = str_starts_with( $textVar, 'old_' ) ? 'old_links' : 'all_links';
243                if ( $parameters['forFilter'] ?? false ) {
244                    $this->logger->debug( "Loading $varName from DB" );
245                    $links = $this->getLinksFromDB( $article );
246                } elseif ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) {
247                    $this->logger->debug( "Loading $varName from Parser" );
248
249                    $wikitext = $getVarCB( $textVar )->toString();
250                    $editInfo = $this->parseNonEditWikitext(
251                        $wikitext,
252                        $article,
253                        $parameters['contextUserIdentity']
254                    );
255                    $links = LinkFilter::getIndexedUrlsNonReversed(
256                        array_keys( $editInfo->output->getExternalLinks() )
257                    );
258                } else {
259                    // TODO: Get links from Content object. But we don't have the content object.
260                    // And for non-text content, $wikitext is usually not going to be a valid
261                    // serialization, but rather some dummy text for filtering.
262                    $links = [];
263                }
264
265                $result = $links;
266                break;
267            case 'links-from-update':
268                /** @var PreparedUpdate $update */
269                $update = $parameters['update'];
270                // Shared with the edit, don't count it in profiling
271                $startTime = microtime( true );
272                $result = LinkFilter::getIndexedUrlsNonReversed(
273                    array_keys( $update->getParserOutputForMetaData()->getExternalLinks() )
274                );
275                self::$profilingExtraTime += ( microtime( true ) - $startTime );
276                break;
277            case 'links-from-database':
278                /** @var WikiPage $article */
279                $article = $parameters['article'];
280                $this->logger->debug( 'Loading old_links from DB' );
281                $result = $this->getLinksFromDB( $article );
282                break;
283            case 'parse-wikitext':
284                // Should ONLY be used when sharing a parse operation with the edit.
285                // TODO: use Content object instead, if available!
286                /* @var WikiPage $article */
287                $article = $parameters['article'];
288                if ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) {
289                    // Shared with the edit, don't count it in profiling
290                    $startTime = microtime( true );
291                    $textVar = $parameters['wikitext-var'];
292
293                    $new_text = $getVarCB( $textVar )->toString();
294                    $content = ContentHandler::makeContent( $new_text, $article->getTitle() );
295                    $editInfo = $article->prepareContentForEdit(
296                        $content,
297                        null,
298                        $parameters['contextUserIdentity']
299                    );
300                    if ( isset( $parameters['pst'] ) && $parameters['pst'] ) {
301                        $result = $editInfo->pstContent->serialize( $editInfo->format );
302                    } else {
303                        // Note: as of core change r727361, the PP limit comments (which we don't want to be here)
304                        // are already excluded.
305                        $result = $editInfo->getOutput()->getText();
306                    }
307                    self::$profilingExtraTime += ( microtime( true ) - $startTime );
308                } else {
309                    $result = '';
310                }
311                break;
312            case 'html-from-update':
313                /** @var PreparedUpdate $update */
314                $update = $parameters['update'];
315                // Shared with the edit, don't count it in profiling
316                $startTime = microtime( true );
317                $result = $update->getCanonicalParserOutput()->getText();
318                self::$profilingExtraTime += ( microtime( true ) - $startTime );
319                break;
320            case 'strip-html':
321                $htmlVar = $parameters['html-var'];
322                $html = $getVarCB( $htmlVar )->toString();
323                $stripped = StringUtils::delimiterReplace( '<', '>', '', $html );
324                // We strip extra spaces to the right because the stripping above
325                // could leave a lot of whitespace.
326                // @fixme Find a better way to do this.
327                $result = TextContent::normalizeLineEndings( $stripped );
328                break;
329            case 'load-recent-authors':
330                $result = $this->getLastPageAuthors( $parameters['title'] );
331                break;
332            case 'load-first-author':
333                $revision = $this->revisionLookup->getFirstRevision( $parameters['title'] );
334                if ( $revision ) {
335                    // TODO T233241
336                    $user = $revision->getUser();
337                    $result = $user === null ? '' : $user->getName();
338                } else {
339                    $result = '';
340                }
341                break;
342            case 'get-page-restrictions':
343                $action = $parameters['action'];
344                /** @var Title $title */
345                $title = $parameters['title'];
346                $result = $this->restrictionStore->getRestrictions( $title, $action );
347                break;
348            case 'user-unnamed-ip':
349                $user = $parameters['user'];
350                $result = null;
351
352                // Don't return an IP for past events (eg. revisions, logs)
353                // This could leak IPs to users who don't have IP viewing rights
354                if ( !$parameters['rc'] &&
355                    // Reveal IPs for:
356                    // - temporary accounts: temporary account names will replace the IP in the `user_name`
357                    //   variable. This variable restores this access.
358                    // - logged-out users: This supports the transition to the use of temporary accounts
359                    //   so that filter maintainers on pre-transition wikis can migrate `user_name` to `user_unnamed_ip`
360                    //   where necessary and see no disruption on transition.
361                    //
362                    // This variable should only ever be exposed for these use cases and shouldn't be extended
363                    // to registered accounts, as that would leak account PII to users without the right to see
364                    // that information
365                    ( $this->userIdentityUtils->isTemp( $user ) || IPUtils::isIPAddress( $user->getName() ) ) ) {
366                    $result = $user->getRequest()->getIP();
367                }
368                break;
369            case 'user-type':
370                /** @var UserIdentity $userIdentity */
371                $userIdentity = $parameters['user-identity'];
372                if ( $this->userIdentityUtils->isNamed( $userIdentity ) ) {
373                    $result = 'named';
374                } elseif ( $this->userIdentityUtils->isTemp( $userIdentity ) ) {
375                    $result = 'temp';
376                } elseif ( IPUtils::isIPAddress( $userIdentity->getName() ) ) {
377                    $result = 'ip';
378                } elseif ( ExternalUserNames::isExternal( $userIdentity->getName() ) ) {
379                    $result = 'external';
380                } else {
381                    $result = 'unknown';
382                }
383                break;
384            case 'user-editcount':
385                /** @var UserIdentity $userIdentity */
386                $userIdentity = $parameters['user-identity'];
387                $result = $this->userEditTracker->getUserEditCount( $userIdentity );
388                break;
389            case 'user-emailconfirm':
390                /** @var User $user */
391                $user = $parameters['user'];
392                $result = $user->getEmailAuthenticationTimestamp();
393                break;
394            case 'user-groups':
395                /** @var UserIdentity $userIdentity */
396                $userIdentity = $parameters['user-identity'];
397                $result = $this->userGroupManager->getUserEffectiveGroups( $userIdentity );
398                break;
399            case 'user-rights':
400                /** @var UserIdentity $userIdentity */
401                $userIdentity = $parameters['user-identity'];
402                $result = $this->permissionManager->getUserPermissions( $userIdentity );
403                break;
404            case 'user-block':
405                // @todo Support partial blocks?
406                /** @var User $user */
407                $user = $parameters['user'];
408                $result = (bool)$user->getBlock();
409                break;
410            case 'user-age':
411                /** @var User $user */
412                $user = $parameters['user'];
413                $asOf = $parameters['asof'];
414
415                if ( !$user->isRegistered() ) {
416                    $result = 0;
417                } else {
418                    // HACK: If there's no registration date, assume 2008-01-15, Wikipedia Day
419                    // in the year before the new user log was created. See T243469.
420                    $registration = $user->getRegistration() ?? "20080115000000";
421                    $result = (int)wfTimestamp( TS_UNIX, $asOf ) - (int)wfTimestamp( TS_UNIX, $registration );
422                }
423                break;
424            case 'page-age':
425                /** @var Title $title */
426                $title = $parameters['title'];
427
428                $firstRev = $this->revisionLookup->getFirstRevision( $title );
429                $firstRevisionTime = $firstRev ? $firstRev->getTimestamp() : null;
430                if ( !$firstRevisionTime ) {
431                    $result = 0;
432                    break;
433                }
434
435                $asOf = $parameters['asof'];
436                $result = (int)wfTimestamp( TS_UNIX, $asOf ) - (int)wfTimestamp( TS_UNIX, $firstRevisionTime );
437                break;
438            case 'revision-age-by-id':
439                $timestamp = $this->revisionLookup->getTimestampFromId( $parameters['revid'] );
440                if ( !$timestamp ) {
441                    $result = null;
442                    break;
443                }
444                $asOf = $parameters['asof'];
445                $result = (int)wfTimestamp( TS_UNIX, $asOf ) - (int)wfTimestamp( TS_UNIX, $timestamp );
446                break;
447            case 'revision-age-by-title':
448                /** @var Title $title */
449                $title = $parameters['title'];
450                $revRec = $this->revisionLookup->getRevisionByTitle( $title );
451                if ( !$revRec ) {
452                    $result = null;
453                    break;
454                }
455                $asOf = $parameters['asof'];
456                $result = (int)wfTimestamp( TS_UNIX, $asOf ) - (int)wfTimestamp( TS_UNIX, $revRec->getTimestamp() );
457                break;
458            case 'previous-revision-age':
459                $revRec = $this->revisionLookup->getRevisionById( $parameters['revid'] );
460                if ( !$revRec ) {
461                    $result = null;
462                    break;
463                }
464                $prev = $this->revisionLookup->getPreviousRevision( $revRec );
465                if ( !$prev ) {
466                    $result = null;
467                    break;
468                }
469                $asOf = $parameters['asof'] ?? $revRec->getTimestamp();
470                $result = (int)wfTimestamp( TS_UNIX, $asOf ) - (int)wfTimestamp( TS_UNIX, $prev->getTimestamp() );
471                break;
472            case 'length':
473                $s = $getVarCB( $parameters['length-var'] )->toString();
474                $result = strlen( $s );
475                break;
476            case 'subtract-int':
477                $v1 = $getVarCB( $parameters['val1-var'] )->toInt();
478                $v2 = $getVarCB( $parameters['val2-var'] )->toInt();
479                $result = $v1 - $v2;
480                break;
481            case 'content-model-by-id':
482                $revRec = $this->revisionLookup->getRevisionById( $parameters['revid'] );
483                $result = $this->getContentModelFromRevision( $revRec );
484                break;
485            case 'revision-text-by-id':
486                $revRec = $this->revisionLookup->getRevisionById( $parameters['revid'] );
487                $result = $this->textExtractor->revisionToString( $revRec, $parameters['contextUser'] );
488                break;
489            case 'get-wiki-name':
490                $result = $this->wikiID;
491                break;
492            case 'get-wiki-language':
493                $result = $this->contentLanguage->getCode();
494                break;
495            default:
496                if ( $this->hookRunner->onAbuseFilter_computeVariable(
497                    $varMethod,
498                    $vars,
499                    $parameters,
500                    $result
501                ) ) {
502                    throw new UnexpectedValueException( 'Unknown variable compute type ' . $varMethod );
503                }
504        }
505
506        return $result instanceof AFPData ? $result : AFPData::newFromPHPVar( $result );
507    }
508
509    /**
510     * @param WikiPage $article
511     * @return array
512     */
513    private function getLinksFromDB( WikiPage $article ) {
514        $id = $article->getId();
515        if ( !$id ) {
516            return [];
517        }
518
519        return ExternalLinksLookup::getExternalLinksForPage(
520            $id,
521            $this->lbFactory->getReplicaDatabase(),
522            __METHOD__
523        );
524    }
525
526    /**
527     * @todo Move to MW core (T272050)
528     * @param Title $title
529     * @return string[] Usernames of the last 10 (unique) authors from $title
530     */
531    private function getLastPageAuthors( Title $title ) {
532        if ( !$title->exists() ) {
533            return [];
534        }
535
536        $fname = __METHOD__;
537
538        return $this->wanCache->getWithSetCallback(
539            $this->wanCache->makeKey( 'last-10-authors', 'revision', $title->getLatestRevID() ),
540            WANObjectCache::TTL_MINUTE,
541            function ( $oldValue, &$ttl, array &$setOpts ) use ( $title, $fname ) {
542                $dbr = $this->lbFactory->getReplicaDatabase();
543
544                $setOpts += Database::getCacheSetOptions( $dbr );
545                // Get the last 100 edit authors with a trivial query (avoid T116557)
546                $revQuery = $this->revisionStore->getQueryInfo();
547                $revAuthors = $dbr->newSelectQueryBuilder()
548                    ->tables( $revQuery['tables'] )
549                    ->field( $revQuery['fields']['rev_user_text'] )
550                    ->where( [
551                        'rev_page' => $title->getArticleID(),
552                        // TODO Should deleted names be counted in the 10 authors? If yes, this check should
553                        // be moved inside the foreach
554                        'rev_deleted' => 0
555                    ] )
556                    ->caller( $fname )
557                    // Some pages have < 10 authors but many revisions (e.g. bot pages)
558                    ->orderBy( [ 'rev_timestamp', 'rev_id' ], SelectQueryBuilder::SORT_DESC )
559                    ->limit( 100 )
560                    // Force index per T116557
561                    ->useIndex( [ 'revision' => 'rev_page_timestamp' ] )
562                    ->joinConds( $revQuery['joins'] )
563                    ->fetchFieldValues();
564                // Get the last 10 distinct authors within this set of edits
565                $users = [];
566                foreach ( $revAuthors as $author ) {
567                    $users[$author] = 1;
568                    if ( count( $users ) >= 10 ) {
569                        break;
570                    }
571                }
572
573                return array_keys( $users );
574            }
575        );
576    }
577
578    /**
579     * @param ?RevisionRecord $revision
580     * @return string
581     */
582    private function getContentModelFromRevision( ?RevisionRecord $revision ): string {
583        // this is consistent with what is done on various places in RunVariableGenerator
584        // and RCVariableGenerator
585        if ( $revision !== null ) {
586            $content = $revision->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
587            return $content->getModel();
588        }
589        return '';
590    }
591
592    /**
593     * It's like WikiPage::prepareContentForEdit, but not for editing (old wikitext usually)
594     *
595     * @param string $wikitext
596     * @param WikiPage $article
597     * @param UserIdentity $userIdentity Context user
598     *
599     * @return stdClass
600     */
601    private function parseNonEditWikitext( $wikitext, WikiPage $article, UserIdentity $userIdentity ) {
602        static $cache = [];
603
604        $cacheKey = md5( $wikitext ) . ':' . $article->getTitle()->getPrefixedText();
605
606        if ( !isset( $cache[$cacheKey] ) ) {
607            $options = ParserOptions::newFromUser( $userIdentity );
608            $cache[$cacheKey] = (object)[
609                'output' => $this->parserFactory->getInstance()->parse( $wikitext, $article->getTitle(), $options )
610            ];
611        }
612
613        return $cache[$cacheKey];
614    }
615}