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