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