Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
17.25% covered (danger)
17.25%
84 / 487
0.00% covered (danger)
0.00%
0 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
LogEventsList
17.28% covered (danger)
17.28%
84 / 486
0.00% covered (danger)
0.00%
0 / 19
9694.36
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getLinkRenderer
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 showOptions
0.00% covered (danger)
0.00%
0 / 67
0.00% covered (danger)
0.00%
0 / 1
182
 getFiltersDesc
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 getTypeMenuDesc
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
20
 getExtraInputsDesc
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 getActionSelectorDesc
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 beginLogEventsList
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 endLogEventsList
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logLine
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
12
 getShowHideLinks
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
272
 typeAction
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 userCan
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 userCanBitfield
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 userCanViewLogType
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 isDeleted
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 showLogExtract
0.00% covered (danger)
0.00%
0 / 131
0.00% covered (danger)
0.00%
0 / 1
992
 getExcludeClause
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
72
 getBlockLogWarningBox
89.36% covered (warning)
89.36%
84 / 94
0.00% covered (danger)
0.00%
0 / 1
27.88
1<?php
2/**
3 * Contain classes to list log entries
4 *
5 * Copyright © 2004 Brooke Vibber <bvibber@wikimedia.org>
6 * https://www.mediawiki.org/
7 *
8 * @license GPL-2.0-or-later
9 * @file
10 */
11
12namespace MediaWiki\Logging;
13
14use InvalidArgumentException;
15use MediaWiki\Block\DatabaseBlockStore;
16use MediaWiki\ChangeTags\ChangeTags;
17use MediaWiki\Context\ContextSource;
18use MediaWiki\Context\IContextSource;
19use MediaWiki\Context\RequestContext;
20use MediaWiki\HookContainer\HookRunner;
21use MediaWiki\Html\Html;
22use MediaWiki\HTMLForm\Field\HTMLMultiSelectField;
23use MediaWiki\HTMLForm\Field\HTMLSelectField;
24use MediaWiki\HTMLForm\Field\HTMLTitleTextField;
25use MediaWiki\HTMLForm\Field\HTMLUserTextField;
26use MediaWiki\HTMLForm\HTMLForm;
27use MediaWiki\Linker\Linker;
28use MediaWiki\Linker\LinkRenderer;
29use MediaWiki\Logger\LoggerFactory;
30use MediaWiki\MainConfigNames;
31use MediaWiki\MediaWikiServices;
32use MediaWiki\Output\OutputPage;
33use MediaWiki\Page\PageReference;
34use MediaWiki\Pager\LogPager;
35use MediaWiki\Parser\Sanitizer;
36use MediaWiki\Permissions\Authority;
37use MediaWiki\SpecialPage\SpecialPage;
38use MediaWiki\Status\Status;
39use MediaWiki\Title\NamespaceInfo;
40use MediaWiki\Title\Title;
41use MediaWiki\User\TempUser\TempUserConfig;
42use MediaWiki\User\UserIdentity;
43use MessageLocalizer;
44use stdClass;
45use UnexpectedValueException;
46use Wikimedia\IPUtils;
47use Wikimedia\MapCacheLRU\MapCacheLRU;
48use Wikimedia\Rdbms\IExpression;
49use Wikimedia\Rdbms\LikeMatch;
50use Wikimedia\Rdbms\LikeValue;
51
52class LogEventsList extends ContextSource {
53    public const NO_ACTION_LINK = 1;
54    public const NO_EXTRA_USER_LINKS = 2;
55    public const USE_CHECKBOXES = 4;
56
57    /** @var int */
58    public $flags;
59
60    /**
61     * @var bool
62     */
63    protected $showTagEditUI;
64
65    /**
66     * @var LinkRenderer|null
67     */
68    private $linkRenderer;
69
70    /** @var HookRunner */
71    private $hookRunner;
72
73    private LogFormatterFactory $logFormatterFactory;
74
75    /** @var MapCacheLRU */
76    private $tagsCache;
77
78    private TempUserConfig $tempUserConfig;
79
80    /**
81     * @param IContextSource $context
82     * @param LinkRenderer|null $linkRenderer
83     * @param int $flags Can be a combination of self::NO_ACTION_LINK,
84     *   self::NO_EXTRA_USER_LINKS or self::USE_CHECKBOXES.
85     */
86    public function __construct( $context, $linkRenderer = null, $flags = 0 ) {
87        $this->setContext( $context );
88        $this->flags = $flags;
89        $this->showTagEditUI = ChangeTags::showTagEditingUI( $this->getAuthority() );
90        if ( $linkRenderer instanceof LinkRenderer ) {
91            $this->linkRenderer = $linkRenderer;
92        }
93        $services = MediaWikiServices::getInstance();
94        $this->hookRunner = new HookRunner( $services->getHookContainer() );
95        $this->logFormatterFactory = $services->getLogFormatterFactory();
96        $this->tagsCache = new MapCacheLRU( 50 );
97        $this->tempUserConfig = $services->getTempUserConfig();
98    }
99
100    /**
101     * @since 1.30
102     * @return LinkRenderer
103     */
104    protected function getLinkRenderer() {
105        if ( $this->linkRenderer !== null ) {
106            return $this->linkRenderer;
107        } else {
108            return MediaWikiServices::getInstance()->getLinkRenderer();
109        }
110    }
111
112    /**
113     * Show options for the log list
114     *
115     * @param string $type Log type
116     * @param int|string $year Use 0 to start with no year preselected.
117     * @param int|string $month A month in the 1..12 range. Use 0 to start with no month
118     *  preselected.
119     * @param int|string $day A day in the 1..31 range. Use 0 to start with no month
120     *  preselected.
121     * @return bool Whether the options are valid
122     */
123    public function showOptions( $type = '', $year = 0, $month = 0, $day = 0 ) {
124        $formDescriptor = [];
125
126        // Basic selectors
127        $formDescriptor['type'] = $this->getTypeMenuDesc();
128        $formDescriptor['user'] = [
129            'class' => HTMLUserTextField::class,
130            'label-message' => 'specialloguserlabel',
131            'name' => 'user',
132            'ipallowed' => true,
133            'iprange' => true,
134            'external' => true,
135        ];
136        $formDescriptor['page'] = [
137            'class' => HTMLTitleTextField::class,
138            'label-message' => 'speciallogtitlelabel',
139            'name' => 'page',
140            'required' => false,
141        ];
142
143        // Title pattern, if allowed
144        if ( !$this->getConfig()->get( MainConfigNames::MiserMode ) ) {
145            $formDescriptor['pattern'] = [
146                'type' => 'check',
147                'label-message' => 'log-title-wildcard',
148                'name' => 'pattern',
149            ];
150        }
151
152        // Add extra inputs if any
153        $extraInputsDescriptor = $this->getExtraInputsDesc( $type );
154
155        // Single inputs (array of attributes) and multiple inputs (array of arrays)
156        // are supported. Distinguish between the two by checking if the first element
157        // is an array or not.
158        if ( $extraInputsDescriptor ) {
159            if ( isset( $extraInputsDescriptor[0] ) && is_array( $extraInputsDescriptor[0] ) ) {
160                foreach ( $extraInputsDescriptor as $i => $input ) {
161                    $formDescriptor[ 'extra_' . $i ] = $input;
162                }
163            } else {
164                $formDescriptor[ 'extra' ] = $extraInputsDescriptor;
165            }
166        }
167
168        // Date menu
169        $formDescriptor['date'] = [
170            'type' => 'date',
171            'label-message' => 'date',
172            'default' => $year && $month && $day ? sprintf( "%04d-%02d-%02d", $year, $month, $day ) : '',
173        ];
174
175        // Tag filter
176        $formDescriptor['tagfilter'] = [
177            'type' => 'tagfilter',
178            'name' => 'tagfilter',
179            'label-message' => 'tag-filter',
180        ];
181        $formDescriptor['tagInvert'] = [
182            'type' => 'check',
183            'name' => 'tagInvert',
184            'label-message' => 'invert',
185            'hide-if' => [ '===', 'tagfilter', '' ],
186        ];
187
188        // Filter checkboxes, when work on all logs
189        if ( $type === '' ) {
190            $formDescriptor['filters'] = $this->getFiltersDesc();
191        }
192
193        // Action filter
194        $allowedActions = $this->getConfig()->get( MainConfigNames::ActionFilteredLogs );
195        if ( isset( $allowedActions[$type] ) ) {
196            $formDescriptor['subtype'] = $this->getActionSelectorDesc( $type, $allowedActions[$type] );
197        }
198
199        $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
200        $htmlForm
201            ->setTitle( SpecialPage::getTitleFor( 'Log' ) ) // Remove subpage
202            ->setSubmitTextMsg( 'logeventslist-submit' )
203            ->setMethod( 'GET' )
204            ->setWrapperLegendMsg( 'log' )
205            ->setFormIdentifier( 'logeventslist', true ) // T321154
206            // Set callback for data validation and log type description.
207            ->setSubmitCallback( static function ( $formData, $form ) {
208                $form->addPreHtml(
209                    ( new LogPage( $formData['type'] ) )->getDescription()
210                        ->setContext( $form->getContext() )->parseAsBlock()
211                );
212                return true;
213            } );
214
215        $result = $htmlForm->prepareForm()->trySubmit();
216        $htmlForm->displayForm( $result );
217        return $result === true || ( $result instanceof Status && $result->isGood() );
218    }
219
220    /**
221     * @return array Form descriptor
222     */
223    private function getFiltersDesc() {
224        $optionsMsg = [];
225        $filters = $this->getConfig()->get( MainConfigNames::FilterLogTypes );
226        foreach ( $filters as $type => $val ) {
227            $optionsMsg["logeventslist-{$type}-log"] = $type;
228        }
229        return [
230            'class' => HTMLMultiSelectField::class,
231            'label-message' => 'logeventslist-more-filters',
232            'flatlist' => true,
233            'options-messages' => $optionsMsg,
234            'default' => array_keys( array_intersect( $filters, [ false ] ) ),
235        ];
236    }
237
238    /**
239     * @return array Form descriptor
240     */
241    private function getTypeMenuDesc() {
242        $typesByName = [];
243        // Load the log names
244        foreach ( LogPage::validTypes() as $type ) {
245            $page = new LogPage( $type );
246            $pageText = $page->getName()->text();
247            if ( in_array( $pageText, $typesByName ) ) {
248                LoggerFactory::getInstance( 'translation-problem' )->error(
249                    'The log type {log_type_one} has the same translation as {log_type_two} for {lang}. ' .
250                    '{log_type_one} will not be displayed in the drop down menu on Special:Log.',
251                    [
252                        'log_type_one' => $type,
253                        'log_type_two' => array_search( $pageText, $typesByName ),
254                        'lang' => $this->getLanguage()->getCode(),
255                    ]
256                );
257                continue;
258            }
259            if ( $this->getAuthority()->isAllowed( $page->getRestriction() ) ) {
260                $typesByName[$type] = $pageText;
261            }
262        }
263
264        asort( $typesByName );
265
266        // Always put "All public logs" on top
267        $public = $typesByName[''];
268        unset( $typesByName[''] );
269        $typesByName = [ '' => $public ] + $typesByName;
270
271        return [
272            'class' => HTMLSelectField::class,
273            'name' => 'type',
274            'options' => array_flip( $typesByName ),
275        ];
276    }
277
278    /**
279     * @param string $type
280     * @return array Form descriptor
281     */
282    private function getExtraInputsDesc( $type ) {
283        $formDescriptor = [];
284
285        if ( $type === 'suppress' ) {
286            $formDescriptor[] = [
287                'type' => 'text',
288                'label-message' => 'revdelete-offender',
289                'name' => 'offender',
290            ];
291            return $formDescriptor;
292        }
293
294        if ( $type === 'newusers' || $type === '' ) {
295            // Add option to exclude/include temporary account creations in results,
296            // excluding them by default.
297            if ( $this->tempUserConfig->isKnown() ) {
298                $formDescriptor[] = [
299                        'type' => 'check',
300                        'label-message' => 'newusers-excludetempacct',
301                        'name' => 'excludetempacct',
302                        'default' => true,
303                    ];
304            }
305        }
306
307        // Allow extensions to add an extra input into the descriptor array.
308        $unused = ''; // Deprecated since 1.32, removed in 1.41
309        $this->hookRunner->onLogEventsListGetExtraInputs( $type, $this, $unused, $formDescriptor );
310
311        return $formDescriptor;
312    }
313
314    /**
315     * Drop down menu for selection of actions that can be used to filter the log
316     * @param string $type
317     * @param array $actions
318     * @return array Form descriptor
319     */
320    private function getActionSelectorDesc( $type, $actions ) {
321        $actionOptions = [ 'log-action-filter-all' => '' ];
322
323        foreach ( $actions as $value => $_ ) {
324            $msgKey = "log-action-filter-$type-$value";
325            $actionOptions[ $msgKey ] = $value;
326        }
327
328        return [
329            'class' => HTMLSelectField::class,
330            'name' => 'subtype',
331            'options-messages' => $actionOptions,
332            'label-message' => 'log-action-filter-' . $type,
333        ];
334    }
335
336    /**
337     * @return string
338     */
339    public function beginLogEventsList() {
340        return "<ul class='mw-logevent-loglines'>\n";
341    }
342
343    /**
344     * @return string
345     */
346    public function endLogEventsList() {
347        return "</ul>\n";
348    }
349
350    /**
351     * @param stdClass $row A single row from the result set
352     * @return string Formatted HTML list item
353     */
354    public function logLine( $row ) {
355        $entry = DatabaseLogEntry::newFromRow( $row );
356        $formatter = $this->logFormatterFactory->newFromEntry( $entry );
357        $formatter->setContext( $this->getContext() );
358        $formatter->setShowUserToolLinks( !( $this->flags & self::NO_EXTRA_USER_LINKS ) );
359
360        $time = $this->getLanguage()->userTimeAndDate(
361            $entry->getTimestamp(),
362            $this->getUser()
363        );
364        // Link the time text to the specific log entry, see T207562
365        $timeLink = $this->getLinkRenderer()->makeKnownLink(
366            SpecialPage::getTitleValueFor( 'Log' ),
367            $time,
368            [],
369            [ 'logid' => $entry->getId() ]
370        );
371
372        $action = $formatter->getActionText();
373
374        if ( $this->flags & self::NO_ACTION_LINK ) {
375            $revert = '';
376        } else {
377            $revert = $formatter->getActionLinks();
378            if ( $revert != '' ) {
379                $revert = '<span class="mw-logevent-actionlink">' . $revert . '</span>';
380            }
381        }
382
383        $comment = $formatter->getComment();
384
385        // Some user can hide log items and have review links
386        $del = $this->getShowHideLinks( $row );
387
388        // Any tags...
389        [ $tagDisplay, $newClasses ] = $this->tagsCache->getWithSetCallback(
390            $this->tagsCache->makeKey(
391                $row->ts_tags ?? '',
392                $this->getUser()->getName(),
393                $this->getLanguage()->getCode()
394            ),
395            fn () => ChangeTags::formatSummaryRow(
396                $row->ts_tags,
397                'logevent',
398                $this->getContext()
399            )
400        );
401        $classes = [ 'mw-logline-' . $entry->getType(), ...$newClasses ];
402        $attribs = [
403            'data-mw-logid' => $entry->getId(),
404            'data-mw-logaction' => $entry->getFullType(),
405        ];
406        $ret = "$del $timeLink $action $comment $revert $tagDisplay";
407
408        // Let extensions add data
409        $ret .= Html::openElement( 'span', [ 'class' => 'mw-logevent-tool' ] );
410        // FIXME: this hook assumes that callers will only append to $ret value.
411        // In future this hook should be replaced with a new hook: LogTools that has a
412        // hook interface consistent with DiffTools and HistoryTools.
413        $this->hookRunner->onLogEventsListLineEnding( $this, $ret, $entry, $classes, $attribs );
414        $attribs = array_filter( $attribs,
415            Sanitizer::isReservedDataAttribute( ... ),
416            ARRAY_FILTER_USE_KEY
417        );
418        $ret .= Html::closeElement( 'span' );
419        $attribs['class'] = $classes;
420
421        return Html::rawElement( 'li', $attribs, $ret ) . "\n";
422    }
423
424    /**
425     * @param stdClass $row
426     * @return string
427     */
428    private function getShowHideLinks( $row ) {
429        // We don't want to see the links and
430        if ( $this->flags == self::NO_ACTION_LINK ) {
431            return '';
432        }
433
434        // If change tag editing is available to this user, return the checkbox
435        if ( $this->flags & self::USE_CHECKBOXES && $this->showTagEditUI ) {
436            return Html::check( 'ids[' . $row->log_id . ']', false );
437        }
438
439        // no one can hide items from the suppress log.
440        if ( $row->log_type == 'suppress' ) {
441            return '';
442        }
443
444        $del = '';
445        $authority = $this->getAuthority();
446        // Don't show useless checkbox to people who cannot hide log entries
447        if ( $authority->isAllowed( 'deletedhistory' ) ) {
448            $canHide = $authority->isAllowed( 'deletelogentry' );
449            $canViewSuppressedOnly = $authority->isAllowed( 'viewsuppressed' ) &&
450                !$authority->isAllowed( 'suppressrevision' );
451            $entryIsSuppressed = self::isDeleted( $row, LogPage::DELETED_RESTRICTED );
452            $canViewThisSuppressedEntry = $canViewSuppressedOnly && $entryIsSuppressed;
453            if ( $row->log_deleted || $canHide ) {
454                // Show checkboxes instead of links.
455                if ( $canHide && $this->flags & self::USE_CHECKBOXES && !$canViewThisSuppressedEntry ) {
456                    // If event was hidden from sysops
457                    if ( !self::userCan( $row, LogPage::DELETED_RESTRICTED, $authority ) ) {
458                        $del = Html::check( 'deleterevisions', false, [ 'disabled' => 'disabled' ] );
459                    } else {
460                        $del = Html::check( 'ids[' . $row->log_id . ']', false );
461                    }
462                } else {
463                    // If event was hidden from sysops
464                    if ( !self::userCan( $row, LogPage::DELETED_RESTRICTED, $authority ) ) {
465                        $del = Linker::revDeleteLinkDisabled( $canHide );
466                    } else {
467                        $query = [
468                            'target' => SpecialPage::getTitleFor( 'Log', $row->log_type )->getPrefixedDBkey(),
469                            'type' => 'logging',
470                            'ids' => $row->log_id,
471                        ];
472                        $del = Linker::revDeleteLink(
473                            $query,
474                            $entryIsSuppressed,
475                            $canHide && !$canViewThisSuppressedEntry
476                        );
477                    }
478                }
479            }
480        }
481
482        return $del;
483    }
484
485    /**
486     * @param stdClass $row
487     * @param string|array $type
488     * @param string|array $action
489     * @return bool
490     */
491    public static function typeAction( $row, $type, $action ) {
492        $match = is_array( $type ) ?
493            in_array( $row->log_type, $type ) : $row->log_type == $type;
494        if ( $match ) {
495            $match = is_array( $action ) ?
496                in_array( $row->log_action, $action ) : $row->log_action == $action;
497        }
498
499        return $match;
500    }
501
502    /**
503     * Determine if the current user is allowed to view a particular
504     * field of this log row, if it's marked as deleted and/or restricted log type.
505     *
506     * @param stdClass $row
507     * @param int $field One of LogPage::DELETED_ACTION, ::DELETED_COMMENT, ::DELETED_USER, ::DELETED_RESTRICTED
508     * @param Authority $performer User to check
509     * @return bool
510     */
511    public static function userCan( $row, $field, Authority $performer ) {
512        return self::userCanBitfield( $row->log_deleted, $field, $performer ) &&
513            self::userCanViewLogType( $row->log_type, $performer );
514    }
515
516    /**
517     * Determine if the current user is allowed to view a particular
518     * field of this log row, if it's marked as deleted.
519     *
520     * @param int $bitfield Current field
521     * @param int $field One of LogPage::DELETED_ACTION, ::DELETED_COMMENT, ::DELETED_USER, ::DELETED_RESTRICTED
522     * @param Authority $performer User to check
523     * @return bool
524     */
525    public static function userCanBitfield( $bitfield, $field, Authority $performer ) {
526        if ( $bitfield & $field ) {
527            if ( $bitfield & LogPage::DELETED_RESTRICTED ) {
528                return $performer->isAllowedAny( 'suppressrevision', 'viewsuppressed' );
529            } else {
530                return $performer->isAllowed( 'deletedhistory' );
531            }
532        }
533        return true;
534    }
535
536    /**
537     * Determine if the current user is allowed to view a particular
538     * field of this log row, if it's marked as restricted log type.
539     *
540     * @param string $type
541     * @param Authority $performer User to check
542     * @return bool
543     */
544    public static function userCanViewLogType( $type, Authority $performer ) {
545        $logRestrictions = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::LogRestrictions );
546        if ( isset( $logRestrictions[$type] ) && !$performer->isAllowed( $logRestrictions[$type] ) ) {
547            return false;
548        }
549        return true;
550    }
551
552    /**
553     * @param stdClass $row
554     * @param int $field One of LogPage::DELETED_ACTION, ::DELETED_COMMENT, ::DELETED_USER, ::DELETED_RESTRICTED
555     * @return bool
556     */
557    public static function isDeleted( $row, $field ) {
558        return ( $row->log_deleted & $field ) == $field;
559    }
560
561    /**
562     * Show log extract. Either with text and a box (set $msgKey) or without (don't set $msgKey)
563     *
564     * @param OutputPage|string &$out
565     * @param string|array $types Log types to show
566     * @param string|PageReference|(string|PageReference)[] $pages The page title(s) to show log
567     *   entries for
568     * @param string $user The user who made the log entries
569     * @param array $param Associative Array with the following additional options:
570     * - lim Integer Limit of items to show, default is 50
571     * - conds Array Extra conditions for the query
572     *   (e.g. $dbr->expr( 'log_action', '!=', 'revision' ))
573     * - showIfEmpty boolean Set to false if you don't want any output in case the loglist is empty
574     *   if set to true (default), "No matching items in log" is displayed if loglist is empty
575     * - msgKey Array If you want a nice box with a message, set this to the key of the message.
576     *   First element is the message key, additional optional elements are parameters for the key
577     *   that are processed with wfMessage
578     * - offset Set to overwrite offset parameter in WebRequest
579     *   set to '' to unset offset
580     * - wrap String Wrap the message in html (usually something like "<div ...>$1</div>").
581     * - flags Integer display flags (NO_ACTION_LINK,NO_EXTRA_USER_LINKS)
582     * - useRequestParams boolean Set true to use Pager-related parameters in the WebRequest
583     * - useMaster boolean Use primary DB
584     * - extraUrlParams array|bool Additional url parameters for "full log" link (if it is shown)
585     * - footerHtmlItems: string[] Extra HTML to add as horizontal list items after the
586     *   end of the log
587     * @return int Number of total log items (not limited by $lim)
588     */
589    public static function showLogExtract(
590        &$out, $types = [], $pages = '', $user = '', $param = []
591    ) {
592        $defaultParameters = [
593            'lim' => 25,
594            'conds' => [],
595            'showIfEmpty' => true,
596            'msgKey' => [ '' ],
597            'wrap' => "$1",
598            'flags' => 0,
599            'useRequestParams' => false,
600            'useMaster' => false,
601            'extraUrlParams' => false,
602            'footerHtmlItems' => []
603        ];
604        # The + operator appends elements of remaining keys from the right
605        # handed array to the left handed, whereas duplicated keys are NOT overwritten.
606        $param += $defaultParameters;
607        # Convert $param array to individual variables
608        $lim = $param['lim'];
609        $conds = $param['conds'];
610        $showIfEmpty = $param['showIfEmpty'];
611        $msgKey = $param['msgKey'];
612        $wrap = $param['wrap'];
613        $flags = $param['flags'];
614        $extraUrlParams = $param['extraUrlParams'];
615
616        $useRequestParams = $param['useRequestParams'];
617        if ( !is_array( $msgKey ) ) {
618            $msgKey = [ $msgKey ];
619        }
620
621        // ???
622        // @phan-suppress-next-line PhanRedundantCondition
623        if ( $out instanceof OutputPage ) {
624            $context = $out->getContext();
625        } else {
626            $context = RequestContext::getMain();
627        }
628
629        $services = MediaWikiServices::getInstance();
630        // FIXME: Figure out how to inject this
631        $linkRenderer = $services->getLinkRenderer();
632
633        if ( !is_array( $pages ) ) {
634            $pages = [ $pages ];
635        }
636
637        # Insert list of top 50 (or top $lim) items
638        $loglist = new LogEventsList( $context, $linkRenderer, $flags );
639        $pager = new LogPager(
640            $loglist,
641            $types,
642            $user,
643            $pages,
644            false,
645            $conds,
646            false,
647            false,
648            false,
649            '',
650            '',
651            0,
652            $services->getLinkBatchFactory(),
653            $services->getActorNormalization(),
654            $services->getLogFormatterFactory()
655        );
656        if ( !$useRequestParams ) {
657            # Reset vars that may have been taken from the request
658            $pager->mLimit = 50;
659            $pager->mDefaultLimit = 50;
660            $pager->mOffset = "";
661            $pager->mIsBackwards = false;
662        }
663
664        if ( $param['useMaster'] ) {
665            $pager->mDb = $services->getConnectionProvider()->getPrimaryDatabase();
666        }
667
668        if ( isset( $param['offset'] ) ) { # Tell pager to ignore WebRequest offset
669            $pager->setOffset( $param['offset'] );
670        }
671
672        if ( $lim > 0 ) {
673            $pager->mLimit = $lim;
674        }
675        // Fetch the log rows and build the HTML if needed
676        $logBody = $pager->getBody();
677        $numRows = $pager->getNumRows();
678
679        $s = '';
680        $footerHtmlItems = [];
681
682        if ( $logBody ) {
683            if ( $msgKey[0] ) {
684                // @phan-suppress-next-line PhanParamTooFewUnpack Non-emptiness checked above
685                $msg = $context->msg( ...$msgKey );
686                if ( ( $pages[0] ?? null ) instanceof PageReference ) {
687                    $msg->page( $pages[0] );
688                }
689                $s .= $msg->parseAsBlock();
690            }
691            $s .= $loglist->beginLogEventsList() .
692                $logBody .
693                $loglist->endLogEventsList();
694            // add styles for change tags
695            $context->getOutput()->addModuleStyles( 'mediawiki.interface.helpers.styles' );
696        } elseif ( $showIfEmpty ) {
697            $s = Html::rawElement( 'div', [ 'class' => 'mw-warning-logempty' ],
698                $context->msg( 'logempty' )->parse() );
699        }
700
701        $pageNames = [];
702        foreach ( $pages as $page ) {
703            if ( $page instanceof PageReference ) {
704                $titleFormatter = MediaWikiServices::getInstance()->getTitleFormatter();
705                $pageNames[] = $titleFormatter->getPrefixedDBkey( $page );
706            } elseif ( $page != '' ) {
707                $pageNames[] = $page;
708            }
709        }
710
711        if ( $numRows > $pager->mLimit ) { # Show "Full log" link
712            $urlParam = [];
713            if ( $pageNames ) {
714                $urlParam['page'] = count( $pageNames ) > 1 ? $pageNames : $pageNames[0];
715            }
716
717            if ( $user != '' ) {
718                $urlParam['user'] = $user;
719            }
720
721            if ( !is_array( $types ) ) { # Make it an array, if it isn't
722                $types = [ $types ];
723            }
724
725            # If there is exactly one log type, we can link to Special:Log?type=foo
726            if ( count( $types ) == 1 ) {
727                $urlParam['type'] = $types[0];
728            }
729
730            if ( $extraUrlParams !== false ) {
731                $urlParam = array_merge( $urlParam, $extraUrlParams );
732            }
733
734            $footerHtmlItems[] = $linkRenderer->makeKnownLink(
735                SpecialPage::getTitleFor( 'Log' ),
736                $context->msg( 'log-fulllog' )->text(),
737                [],
738                $urlParam
739            );
740        }
741        if ( $param['footerHtmlItems'] ) {
742            $footerHtmlItems = array_merge( $footerHtmlItems, $param['footerHtmlItems'] );
743        }
744        if ( $logBody && $footerHtmlItems ) {
745            $s .= '<ul class="mw-logevent-footer">';
746            foreach ( $footerHtmlItems as $item ) {
747                $s .= Html::rawElement( 'li', [], $item );
748            }
749            $s .= '</ul>';
750        }
751
752        if ( $logBody && $msgKey[0] ) {
753            // TODO: The condition above is weird. Should this be done in any other cases?
754            // Or is it always true in practice?
755
756            // Mark as interface language (T60685)
757            $dir = $context->getLanguage()->getDir();
758            $lang = $context->getLanguage()->getHtmlCode();
759            $s = Html::rawElement( 'div', [
760                'class' => "mw-content-$dir",
761                'dir' => $dir,
762                'lang' => $lang,
763            ], $s );
764
765            // Wrap in warning box
766            $s = Html::warningBox(
767                $s,
768                'mw-warning-with-logexcerpt'
769            );
770            // Add styles for warning box
771            $context->getOutput()->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
772        }
773
774        if ( $wrap != '' ) { // Wrap message in html
775            $s = str_replace( '$1', $s, $wrap );
776        }
777
778        /* hook can return false, if we don't want the message to be emitted (Wikia BugId:7093) */
779        $hookRunner = new HookRunner( $services->getHookContainer() );
780        if ( $hookRunner->onLogEventsListShowLogExtract(
781            $s, $types, $pageNames, $user, $param
782        ) ) {
783            // $out can be either an OutputPage object or a String-by-reference
784            if ( $out instanceof OutputPage ) {
785                $out->addHTML( $s );
786            } else {
787                $out = $s;
788            }
789        }
790
791        return $numRows;
792    }
793
794    /**
795     * SQL clause to skip forbidden log types for this user
796     *
797     * @param \Wikimedia\Rdbms\IReadableDatabase $db
798     * @param string $audience Public/user
799     * @param Authority|null $performer User to check, required when audience isn't public
800     * @return string|false String on success, false on failure.
801     * @throws InvalidArgumentException
802     */
803    public static function getExcludeClause( $db, $audience = 'public', ?Authority $performer = null ) {
804        $logRestrictions = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::LogRestrictions );
805
806        if ( $audience != 'public' && $performer === null ) {
807            throw new InvalidArgumentException(
808                'A User object must be given when checking for a user audience.'
809            );
810        }
811
812        // Reset the array, clears extra "where" clauses when $par is used
813        $hiddenLogs = [];
814
815        // Don't show private logs to unprivileged users
816        foreach ( $logRestrictions as $logType => $right ) {
817            if ( $audience == 'public' || !$performer->isAllowed( $right ) ) {
818                $hiddenLogs[] = $logType;
819            }
820        }
821        if ( count( $hiddenLogs ) == 1 ) {
822            return 'log_type != ' . $db->addQuotes( $hiddenLogs[0] );
823        } elseif ( $hiddenLogs ) {
824            return 'log_type NOT IN (' . $db->makeList( $hiddenLogs ) . ')';
825        }
826
827        return false;
828    }
829
830    /**
831     * @internal -- shared code for IntroMessageBuilder, Article::showMissingArticle,
832     * and ContributionsSpecialPage::contributionsSub
833     *
834     * If the user associated with the current page is blocked, get a warning
835     * box with a block log extract in it. Otherwise, return null.
836     *
837     * @param DatabaseBlockStore $blockStore
838     * @param NamespaceInfo $namespaceInfo
839     * @param MessageLocalizer $localizer
840     * @param LinkRenderer $linkRenderer
841     * @param UserIdentity|false|null $user The user identity that may be blocked
842     * @param Title|null $title The title being viewed. Pass null if the box
843     *  should be shown regardless of the title.
844     * @param array|callable $additionalParams Either:
845     * - An array of extra parameters for LogEventsList::showLogExtract, or
846     * - A callback returning such an array.
847     *
848     * When a callback is used, it receives a `$data` array with the following keys:
849     * - `blocks: DatabaseBlock[]` - Active blocks matching the target
850     * - `sitewide: bool` - Whether any of the blocks is sitewide
851     * - `logTargetPages: string[]` - Pages used as log targets
852     * @return string|null
853     */
854    public static function getBlockLogWarningBox(
855        DatabaseBlockStore $blockStore,
856        NamespaceInfo $namespaceInfo,
857        MessageLocalizer $localizer,
858        LinkRenderer $linkRenderer,
859        $user,
860        ?Title $title,
861        array|callable $additionalParams = []
862    ) {
863        if ( !$user ) {
864            return null;
865        }
866
867        // For IP ranges we must give DatabaseBlock::newFromTarget the CIDR string
868        // and not a user object
869        $userOrRange = IPUtils::isValidRange( $user->getName() ) ? $user->getName() : $user;
870        $blocks = $blockStore->newListFromTarget(
871            // Do not expose the autoblocks, since that may lead to a leak of accounts' IPs,
872            // and also that will display a totally irrelevant log entry as a current block.
873            $userOrRange, $userOrRange, false, DatabaseBlockStore::AUTO_NONE
874        );
875        if ( !count( $blocks ) ) {
876            return null;
877        }
878
879        $isAnon = !$user->isRegistered();
880        $appliesToTitle = false;
881        $logTargetPages = [];
882        $sitewide = false;
883        $matchingIpFound = false;
884        $newestBlockTimestamp = null;
885        $blockId = null;
886        foreach ( $blocks as $block ) {
887            if ( $title === null || $block->appliesToTitle( $title ) ) {
888                $appliesToTitle = true;
889            }
890            $blockTargetName = $block->getTargetName();
891            $logTargetPages[] =
892                $namespaceInfo->getCanonicalName( NS_USER ) . ':' . $blockTargetName;
893            if ( $block->isSitewide() ) {
894                $sitewide = true;
895            }
896
897            // Track the most recent active block. Prefer newer timestamps; if two blocks
898            // share the same timestamp, fall back to the larger block ID to break ties.
899            // This avoids issues where overridden blocks may reuse smaller IDs.
900            //
901            // IP blocks are a bit tricky here:
902            // - Prioritize direct blocks where $user and $block share the same IP.
903            // - The same IP can be directly blocked multiple times, in which case
904            //   the timestamp priority logic should work the same way.
905            // Once an exact IP match is found, it takes precedence over range blocks
906            // even if the range is newer or has a bigger ID, since it represents a more
907            // specific and directly applicable restriction.
908            $isExactIpMatch = $isAnon && $user->getName() === $blockTargetName;
909            if ( ( $isExactIpMatch || !$matchingIpFound ) && (
910                $newestBlockTimestamp === null ||
911                $block->getTimestamp() > $newestBlockTimestamp ||
912                ( $block->getTimestamp() === $newestBlockTimestamp && $block->getId() > $blockId )
913            ) ) {
914                $newestBlockTimestamp = $block->getTimestamp();
915                $blockId = $block->getId();
916
917                // If this block is an exact IP match, mark it so future range blocks don't
918                // override it, regardless of newer timestamps or bigger IDs
919                if ( $isExactIpMatch ) {
920                    $matchingIpFound = true;
921                }
922            }
923        }
924
925        // Show nothing if no active block applies to the given title
926        // (practically, whether the target user is allowed to edit their user/user_talk page)
927        if ( !$appliesToTitle ) {
928            return null;
929        }
930
931        if ( count( $blocks ) === 1 ) {
932            if ( $isAnon ) {
933                $msgKey = $sitewide ?
934                    'blocked-notice-logextract-anon' :
935                    'blocked-notice-logextract-anon-partial';
936            } else {
937                $msgKey = $sitewide ?
938                    'blocked-notice-logextract' :
939                    'blocked-notice-logextract-partial';
940            }
941        } else {
942            if ( $isAnon ) {
943                $msgKey = 'blocked-notice-logextract-anon-multi';
944            } else {
945                $msgKey = 'blocked-notice-logextract-multi';
946            }
947        }
948
949        // While $blocks already contains only active blocks, LogEventsList::showLogExtract
950        // by default fetches the most recent log entries regardless of block status.
951        // To ensure the newest ACTIVE block log is shown, add explicit LIKE conditions
952        // here to filter block log entries.
953        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
954        $orCondsForBlockId = [];
955        $orCondsForBlockId[] = $dbr->expr(
956            // Before MW 1.44, log_params did not contain blockId. Always include such older
957            // log entries for backwards compatibility
958            'log_params',
959            IExpression::NOT_LIKE,
960            new LikeValue( new LikeMatch( '%"blockId"%' ) )
961        );
962        if ( $blockId !== null ) {
963            $orCondsForBlockId[] = $dbr->expr(
964                'log_params',
965                IExpression::LIKE,
966                new LikeValue( new LikeMatch( "%\"blockId\";i:$blockId;%" ) )
967            );
968        }
969        $conds = [ $dbr->makeList( $orCondsForBlockId, LIST_OR ) ];
970
971        $params = [
972            'lim' => 1,
973            'conds' => $conds,
974            'showIfEmpty' => false,
975            'msgKey' => [
976                $msgKey,
977                $user->getName(), // Support GENDER in $msgKey
978                count( $blocks )
979            ],
980            'offset' => '' // Don't use WebRequest parameter offset
981        ];
982
983        if ( count( $blocks ) > 1 ) {
984            $params['footerHtmlItems'] = [
985                $linkRenderer->makeKnownLink(
986                    SpecialPage::getTitleFor( 'BlockList' ),
987                    $localizer->msg( 'blocked-notice-list-link' )->text(),
988                    [],
989                    [ 'wpTarget' => $user->getName() ]
990                ),
991            ];
992        }
993
994        if ( is_callable( $additionalParams ) ) {
995            $extraParams = $additionalParams( [
996                // Add values to this callback array depending on the needs
997                // Don't forget to also update the method documentation
998                'blocks' => $blocks,
999                'sitewide' => $sitewide,
1000                'logTargetPages' => $logTargetPages
1001            ] );
1002            if ( !is_array( $extraParams ) ) {
1003                throw new UnexpectedValueException(
1004                    'The callable $additionalParams must return an array, ' . gettype( $extraParams ) . ' given'
1005                );
1006            }
1007            $params += $extraParams;
1008        } else {
1009            $params += $additionalParams;
1010        }
1011
1012        $outString = '';
1013        self::showLogExtract( $outString, 'block', $logTargetPages, '', $params );
1014        return $outString ?: null;
1015    }
1016}
1017
1018/** @deprecated class alias since 1.44 */
1019class_alias( LogEventsList::class, 'LogEventsList' );