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