Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
96.53% |
306 / 317 |
|
42.86% |
3 / 7 |
CRAP | |
0.00% |
0 / 1 |
LazyVariableComputer | |
96.53% |
306 / 317 |
|
42.86% |
3 / 7 |
76 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
1 | |||
compute | |
97.49% |
233 / 239 |
|
0.00% |
0 / 1 |
60 | |||
getLinksFromDB | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
getLastPageAuthors | |
96.97% |
32 / 33 |
|
0.00% |
0 / 1 |
4 | |||
getRevisionFromParameters | |
70.00% |
7 / 10 |
|
0.00% |
0 / 1 |
5.68 | |||
getContentModelFromRevision | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
parseNonEditWikitext | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\AbuseFilter\Variables; |
4 | |
5 | use InvalidArgumentException; |
6 | use MediaWiki\Content\ContentHandler; |
7 | use MediaWiki\Content\TextContent; |
8 | use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner; |
9 | use MediaWiki\Extension\AbuseFilter\Parser\AFPData; |
10 | use MediaWiki\Extension\AbuseFilter\TextExtractor; |
11 | use MediaWiki\ExternalLinks\ExternalLinksLookup; |
12 | use MediaWiki\ExternalLinks\LinkFilter; |
13 | use MediaWiki\Language\Language; |
14 | use MediaWiki\Page\PageIdentity; |
15 | use MediaWiki\Page\WikiPage; |
16 | use MediaWiki\Parser\ParserFactory; |
17 | use MediaWiki\Parser\ParserOptions; |
18 | use MediaWiki\Permissions\PermissionManager; |
19 | use MediaWiki\Permissions\RestrictionStore; |
20 | use MediaWiki\Revision\RevisionLookup; |
21 | use MediaWiki\Revision\RevisionRecord; |
22 | use MediaWiki\Revision\RevisionStore; |
23 | use MediaWiki\Revision\SlotRecord; |
24 | use MediaWiki\Storage\PreparedUpdate; |
25 | use MediaWiki\Title\Title; |
26 | use MediaWiki\User\ExternalUserNames; |
27 | use MediaWiki\User\User; |
28 | use MediaWiki\User\UserEditTracker; |
29 | use MediaWiki\User\UserGroupManager; |
30 | use MediaWiki\User\UserIdentity; |
31 | use MediaWiki\User\UserIdentityUtils; |
32 | use Psr\Log\LoggerInterface; |
33 | use stdClass; |
34 | use StringUtils; |
35 | use UnexpectedValueException; |
36 | use Wikimedia\Diff\Diff; |
37 | use Wikimedia\Diff\UnifiedDiffFormatter; |
38 | use Wikimedia\IPUtils; |
39 | use Wikimedia\ObjectCache\WANObjectCache; |
40 | use Wikimedia\Rdbms\Database; |
41 | use Wikimedia\Rdbms\LBFactory; |
42 | use Wikimedia\Rdbms\SelectQueryBuilder; |
43 | |
44 | /** |
45 | * Service used to compute lazy-loaded variable. |
46 | * @internal |
47 | */ |
48 | class 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 | } |