3 * Contain classes to list log entries
5 * Copyright © 2004 Brooke Vibber <bvibber@wikimedia.org>
6 * https://www.mediawiki.org/
8 * This program is free software; you can redistribute it and/or modify
9 * it under the terms of the GNU General Public License as published by
10 * the Free Software Foundation; either version 2 of the License, or
11 * (at your option) any later version.
13 * This program is distributed in the hope that it will be useful,
14 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 * GNU General Public License for more details.
18 * You should have received a copy of the GNU General Public License along
19 * with this program; if not, write to the Free Software Foundation, Inc.,
20 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
21 * http://www.gnu.org/copyleft/gpl.html
26 use MediaWiki\Context\ContextSource
;
27 use MediaWiki\Context\IContextSource
;
28 use MediaWiki\Context\RequestContext
;
29 use MediaWiki\HookContainer\HookRunner
;
30 use MediaWiki\Html\Html
;
31 use MediaWiki\HTMLForm\Field\HTMLMultiSelectField
;
32 use MediaWiki\HTMLForm\Field\HTMLSelectField
;
33 use MediaWiki\HTMLForm\Field\HTMLTitleTextField
;
34 use MediaWiki\HTMLForm\Field\HTMLUserTextField
;
35 use MediaWiki\HTMLForm\HTMLForm
;
36 use MediaWiki\Linker\Linker
;
37 use MediaWiki\Linker\LinkRenderer
;
38 use MediaWiki\Logger\LoggerFactory
;
39 use MediaWiki\MainConfigNames
;
40 use MediaWiki\MediaWikiServices
;
41 use MediaWiki\Output\OutputPage
;
42 use MediaWiki\Page\PageReference
;
43 use MediaWiki\Pager\LogPager
;
44 use MediaWiki\Parser\Sanitizer
;
45 use MediaWiki\Permissions\Authority
;
46 use MediaWiki\SpecialPage\SpecialPage
;
47 use MediaWiki\Status\Status
;
48 use MediaWiki\Xml\Xml
;
50 class LogEventsList
extends ContextSource
{
51 public const NO_ACTION_LINK
= 1;
52 public const NO_EXTRA_USER_LINKS
= 2;
53 public const USE_CHECKBOXES
= 4;
61 protected $showTagEditUI;
64 * @var LinkRenderer|null
66 private $linkRenderer;
68 /** @var HookRunner */
71 private LogFormatterFactory
$logFormatterFactory;
73 /** @var MapCacheLRU */
77 * @param IContextSource $context
78 * @param LinkRenderer|null $linkRenderer
79 * @param int $flags Can be a combination of self::NO_ACTION_LINK,
80 * self::NO_EXTRA_USER_LINKS or self::USE_CHECKBOXES.
82 public function __construct( $context, $linkRenderer = null, $flags = 0 ) {
83 $this->setContext( $context );
84 $this->flags
= $flags;
85 $this->showTagEditUI
= ChangeTags
::showTagEditingUI( $this->getAuthority() );
86 if ( $linkRenderer instanceof LinkRenderer
) {
87 $this->linkRenderer
= $linkRenderer;
89 $services = MediaWikiServices
::getInstance();
90 $this->hookRunner
= new HookRunner( $services->getHookContainer() );
91 $this->logFormatterFactory
= $services->getLogFormatterFactory();
92 $this->tagsCache
= new MapCacheLRU( 50 );
97 * @return LinkRenderer
99 protected function getLinkRenderer() {
100 if ( $this->linkRenderer
!== null ) {
101 return $this->linkRenderer
;
103 return MediaWikiServices
::getInstance()->getLinkRenderer();
108 * Show options for the log list
110 * @param string $type Log type
111 * @param int|string $year Use 0 to start with no year preselected.
112 * @param int|string $month A month in the 1..12 range. Use 0 to start with no month
114 * @param int|string $day A day in the 1..31 range. Use 0 to start with no month
116 * @return bool Whether the options are valid
118 public function showOptions( $type = '', $year = 0, $month = 0, $day = 0 ) {
119 $formDescriptor = [];
122 $formDescriptor['type'] = $this->getTypeMenuDesc();
123 $formDescriptor['user'] = [
124 'class' => HTMLUserTextField
::class,
125 'label-message' => 'specialloguserlabel',
131 $formDescriptor['page'] = [
132 'class' => HTMLTitleTextField
::class,
133 'label-message' => 'speciallogtitlelabel',
138 // Title pattern, if allowed
139 if ( !$this->getConfig()->get( MainConfigNames
::MiserMode
) ) {
140 $formDescriptor['pattern'] = [
142 'label-message' => 'log-title-wildcard',
147 // Add extra inputs if any
148 $extraInputsDescriptor = $this->getExtraInputsDesc( $type );
149 if ( $extraInputsDescriptor ) {
150 $formDescriptor[ 'extra' ] = $extraInputsDescriptor;
154 $formDescriptor['date'] = [
156 'label-message' => 'date',
157 'default' => $year && $month && $day ?
sprintf( "%04d-%02d-%02d", $year, $month, $day ) : '',
161 $formDescriptor['tagfilter'] = [
162 'type' => 'tagfilter',
163 'name' => 'tagfilter',
164 'label-message' => 'tag-filter',
166 $formDescriptor['tagInvert'] = [
168 'name' => 'tagInvert',
169 'label-message' => 'invert',
170 'hide-if' => [ '===', 'tagfilter', '' ],
173 // Filter checkboxes, when work on all logs
174 if ( $type === '' ) {
175 $formDescriptor['filters'] = $this->getFiltersDesc();
179 $allowedActions = $this->getConfig()->get( MainConfigNames
::ActionFilteredLogs
);
180 if ( isset( $allowedActions[$type] ) ) {
181 $formDescriptor['subtype'] = $this->getActionSelectorDesc( $type, $allowedActions[$type] );
184 $htmlForm = HTMLForm
::factory( 'ooui', $formDescriptor, $this->getContext() );
186 ->setTitle( SpecialPage
::getTitleFor( 'Log' ) ) // Remove subpage
187 ->setSubmitTextMsg( 'logeventslist-submit' )
189 ->setWrapperLegendMsg( 'log' )
190 ->setFormIdentifier( 'logeventslist', true ) // T321154
191 // Set callback for data validation and log type description.
192 ->setSubmitCallback( static function ( $formData, $form ) {
194 ( new LogPage( $formData['type'] ) )->getDescription()
195 ->setContext( $form->getContext() )->parseAsBlock()
200 $result = $htmlForm->prepareForm()->trySubmit();
201 $htmlForm->displayForm( $result );
202 return $result === true ||
( $result instanceof Status
&& $result->isGood() );
206 * @return array Form descriptor
208 private function getFiltersDesc() {
210 $filters = $this->getConfig()->get( MainConfigNames
::FilterLogTypes
);
211 foreach ( $filters as $type => $val ) {
212 $optionsMsg["logeventslist-{$type}-log"] = $type;
215 'class' => HTMLMultiSelectField
::class,
216 'label-message' => 'logeventslist-more-filters',
218 'options-messages' => $optionsMsg,
219 'default' => array_keys( array_intersect( $filters, [ false ] ) ),
224 * @return array Form descriptor
226 private function getTypeMenuDesc() {
228 // Load the log names
229 foreach ( LogPage
::validTypes() as $type ) {
230 $page = new LogPage( $type );
231 $pageText = $page->getName()->text();
232 if ( in_array( $pageText, $typesByName ) ) {
233 LoggerFactory
::getInstance( 'translation-problem' )->error(
234 'The log type {log_type_one} has the same translation as {log_type_two} for {lang}. ' .
235 '{log_type_one} will not be displayed in the drop down menu on Special:Log.',
237 'log_type_one' => $type,
238 'log_type_two' => array_search( $pageText, $typesByName ),
239 'lang' => $this->getLanguage()->getCode(),
244 if ( $this->getAuthority()->isAllowed( $page->getRestriction() ) ) {
245 $typesByName[$type] = $pageText;
249 asort( $typesByName );
251 // Always put "All public logs" on top
252 $public = $typesByName[''];
253 unset( $typesByName[''] );
254 $typesByName = [ '' => $public ] +
$typesByName;
257 'class' => HTMLSelectField
::class,
259 'options' => array_flip( $typesByName ),
264 * @param string $type
265 * @return array Form descriptor
267 private function getExtraInputsDesc( $type ) {
268 if ( $type === 'suppress' ) {
271 'label-message' => 'revdelete-offender',
272 'name' => 'offender',
275 // Allow extensions to add an extra input into the descriptor array.
276 $unused = ''; // Deprecated since 1.32, removed in 1.41
277 $formDescriptor = [];
278 $this->hookRunner
->onLogEventsListGetExtraInputs( $type, $this, $unused, $formDescriptor );
280 return $formDescriptor;
285 * Drop down menu for selection of actions that can be used to filter the log
286 * @param string $type
287 * @param array $actions
288 * @return array Form descriptor
290 private function getActionSelectorDesc( $type, $actions ) {
291 $actionOptions = [ 'log-action-filter-all' => '' ];
293 foreach ( $actions as $value => $_ ) {
294 $msgKey = "log-action-filter-$type-$value";
295 $actionOptions[ $msgKey ] = $value;
299 'class' => HTMLSelectField
::class,
301 'options-messages' => $actionOptions,
302 'label-message' => 'log-action-filter-' . $type,
309 public function beginLogEventsList() {
310 return "<ul class='mw-logevent-loglines'>\n";
316 public function endLogEventsList() {
321 * @param stdClass $row A single row from the result set
322 * @return string Formatted HTML list item
324 public function logLine( $row ) {
325 $entry = DatabaseLogEntry
::newFromRow( $row );
326 $formatter = $this->logFormatterFactory
->newFromEntry( $entry );
327 $formatter->setContext( $this->getContext() );
328 $formatter->setShowUserToolLinks( !( $this->flags
& self
::NO_EXTRA_USER_LINKS
) );
330 $time = $this->getLanguage()->userTimeAndDate(
331 $entry->getTimestamp(),
334 // Link the time text to the specific log entry, see T207562
335 $timeLink = $this->getLinkRenderer()->makeKnownLink(
336 SpecialPage
::getTitleValueFor( 'Log' ),
339 [ 'logid' => $entry->getId() ]
342 $action = $formatter->getActionText();
344 if ( $this->flags
& self
::NO_ACTION_LINK
) {
347 $revert = $formatter->getActionLinks();
348 if ( $revert != '' ) {
349 $revert = '<span class="mw-logevent-actionlink">' . $revert . '</span>';
353 $comment = $formatter->getComment();
355 // Some user can hide log items and have review links
356 $del = $this->getShowHideLinks( $row );
359 [ $tagDisplay, $newClasses ] = $this->tagsCache
->getWithSetCallback(
360 $this->tagsCache
->makeKey(
362 $this->getUser()->getName(),
363 $this->getLanguage()->getCode()
365 fn () => ChangeTags
::formatSummaryRow(
371 $classes = array_merge(
372 [ 'mw-logline-' . $entry->getType() ],
376 'data-mw-logid' => $entry->getId(),
377 'data-mw-logaction' => $entry->getFullType(),
379 $ret = "$del $timeLink $action $comment $revert $tagDisplay";
381 // Let extensions add data
382 $this->hookRunner
->onLogEventsListLineEnding( $this, $ret, $entry, $classes, $attribs );
383 $attribs = array_filter( $attribs,
384 [ Sanitizer
::class, 'isReservedDataAttribute' ],
387 $attribs['class'] = $classes;
389 return Html
::rawElement( 'li', $attribs, $ret ) . "\n";
393 * @param stdClass $row
396 private function getShowHideLinks( $row ) {
397 // We don't want to see the links and
398 if ( $this->flags
== self
::NO_ACTION_LINK
) {
402 // If change tag editing is available to this user, return the checkbox
403 if ( $this->flags
& self
::USE_CHECKBOXES
&& $this->showTagEditUI
) {
407 [ 'name' => 'ids[' . $row->log_id
. ']' ]
411 // no one can hide items from the suppress log.
412 if ( $row->log_type
== 'suppress' ) {
417 $authority = $this->getAuthority();
418 // Don't show useless checkbox to people who cannot hide log entries
419 if ( $authority->isAllowed( 'deletedhistory' ) ) {
420 $canHide = $authority->isAllowed( 'deletelogentry' );
421 $canViewSuppressedOnly = $authority->isAllowed( 'viewsuppressed' ) &&
422 !$authority->isAllowed( 'suppressrevision' );
423 $entryIsSuppressed = self
::isDeleted( $row, LogPage
::DELETED_RESTRICTED
);
424 $canViewThisSuppressedEntry = $canViewSuppressedOnly && $entryIsSuppressed;
425 if ( $row->log_deleted ||
$canHide ) {
426 // Show checkboxes instead of links.
427 if ( $canHide && $this->flags
& self
::USE_CHECKBOXES
&& !$canViewThisSuppressedEntry ) {
428 // If event was hidden from sysops
429 if ( !self
::userCan( $row, LogPage
::DELETED_RESTRICTED
, $authority ) ) {
430 $del = Xml
::check( 'deleterevisions', false, [ 'disabled' => 'disabled' ] );
435 [ 'name' => 'ids[' . $row->log_id
. ']' ]
439 // If event was hidden from sysops
440 if ( !self
::userCan( $row, LogPage
::DELETED_RESTRICTED
, $authority ) ) {
441 $del = Linker
::revDeleteLinkDisabled( $canHide );
444 'target' => SpecialPage
::getTitleFor( 'Log', $row->log_type
)->getPrefixedDBkey(),
446 'ids' => $row->log_id
,
448 $del = Linker
::revDeleteLink(
451 $canHide && !$canViewThisSuppressedEntry
462 * @param stdClass $row
463 * @param string|array $type
464 * @param string|array $action
467 public static function typeAction( $row, $type, $action ) {
468 $match = is_array( $type ) ?
469 in_array( $row->log_type
, $type ) : $row->log_type
== $type;
471 $match = is_array( $action ) ?
472 in_array( $row->log_action
, $action ) : $row->log_action
== $action;
479 * Determine if the current user is allowed to view a particular
480 * field of this log row, if it's marked as deleted and/or restricted log type.
482 * @param stdClass $row
483 * @param int $field One of LogPage::DELETED_ACTION, ::DELETED_COMMENT, ::DELETED_USER, ::DELETED_RESTRICTED
484 * @param Authority $performer User to check
487 public static function userCan( $row, $field, Authority
$performer ) {
488 return self
::userCanBitfield( $row->log_deleted
, $field, $performer ) &&
489 self
::userCanViewLogType( $row->log_type
, $performer );
493 * Determine if the current user is allowed to view a particular
494 * field of this log row, if it's marked as deleted.
496 * @param int $bitfield Current field
497 * @param int $field One of LogPage::DELETED_ACTION, ::DELETED_COMMENT, ::DELETED_USER, ::DELETED_RESTRICTED
498 * @param Authority $performer User to check
501 public static function userCanBitfield( $bitfield, $field, Authority
$performer ) {
502 if ( $bitfield & $field ) {
503 if ( $bitfield & LogPage
::DELETED_RESTRICTED
) {
504 return $performer->isAllowedAny( 'suppressrevision', 'viewsuppressed' );
506 return $performer->isAllowed( 'deletedhistory' );
513 * Determine if the current user is allowed to view a particular
514 * field of this log row, if it's marked as restricted log type.
516 * @param string $type
517 * @param Authority $performer User to check
520 public static function userCanViewLogType( $type, Authority
$performer ) {
521 $logRestrictions = MediaWikiServices
::getInstance()->getMainConfig()->get( MainConfigNames
::LogRestrictions
);
522 if ( isset( $logRestrictions[$type] ) && !$performer->isAllowed( $logRestrictions[$type] ) ) {
529 * @param stdClass $row
530 * @param int $field One of LogPage::DELETED_ACTION, ::DELETED_COMMENT, ::DELETED_USER, ::DELETED_RESTRICTED
533 public static function isDeleted( $row, $field ) {
534 return ( $row->log_deleted
& $field ) == $field;
538 * Show log extract. Either with text and a box (set $msgKey) or without (don't set $msgKey)
540 * @param OutputPage|string &$out
541 * @param string|array $types Log types to show
542 * @param string|PageReference $page The page title to show log entries for
543 * @param string $user The user who made the log entries
544 * @param array $param Associative Array with the following additional options:
545 * - lim Integer Limit of items to show, default is 50
546 * - conds Array Extra conditions for the query
547 * (e.g. $dbr->expr( 'log_action', '!=', 'revision' ))
548 * - showIfEmpty boolean Set to false if you don't want any output in case the loglist is empty
549 * if set to true (default), "No matching items in log" is displayed if loglist is empty
550 * - msgKey Array If you want a nice box with a message, set this to the key of the message.
551 * First element is the message key, additional optional elements are parameters for the key
552 * that are processed with wfMessage
553 * - offset Set to overwrite offset parameter in WebRequest
554 * set to '' to unset offset
555 * - wrap String Wrap the message in html (usually something like "<div ...>$1</div>").
556 * - flags Integer display flags (NO_ACTION_LINK,NO_EXTRA_USER_LINKS)
557 * - useRequestParams boolean Set true to use Pager-related parameters in the WebRequest
558 * - useMaster boolean Use primary DB
559 * - extraUrlParams array|bool Additional url parameters for "full log" link (if it is shown)
560 * @return int Number of total log items (not limited by $lim)
562 public static function showLogExtract(
563 &$out, $types = [], $page = '', $user = '', $param = []
565 $defaultParameters = [
568 'showIfEmpty' => true,
572 'useRequestParams' => false,
573 'useMaster' => false,
574 'extraUrlParams' => false,
576 # The + operator appends elements of remaining keys from the right
577 # handed array to the left handed, whereas duplicated keys are NOT overwritten.
578 $param +
= $defaultParameters;
579 # Convert $param array to individual variables
580 $lim = $param['lim'];
581 $conds = $param['conds'];
582 $showIfEmpty = $param['showIfEmpty'];
583 $msgKey = $param['msgKey'];
584 $wrap = $param['wrap'];
585 $flags = $param['flags'];
586 $extraUrlParams = $param['extraUrlParams'];
588 $useRequestParams = $param['useRequestParams'];
589 // @phan-suppress-next-line PhanRedundantCondition
590 if ( !is_array( $msgKey ) ) {
591 $msgKey = [ $msgKey ];
595 // @phan-suppress-next-line PhanRedundantCondition
596 if ( $out instanceof OutputPage
) {
597 $context = $out->getContext();
599 $context = RequestContext
::getMain();
602 $services = MediaWikiServices
::getInstance();
603 // FIXME: Figure out how to inject this
604 $linkRenderer = $services->getLinkRenderer();
606 # Insert list of top 50 (or top $lim) items
607 $loglist = new LogEventsList( $context, $linkRenderer, $flags );
608 $pager = new LogPager(
621 $services->getLinkBatchFactory(),
622 $services->getActorNormalization(),
623 $services->getLogFormatterFactory()
625 // @phan-suppress-next-line PhanImpossibleCondition
626 if ( !$useRequestParams ) {
627 # Reset vars that may have been taken from the request
629 $pager->mDefaultLimit
= 50;
630 $pager->mOffset
= "";
631 $pager->mIsBackwards
= false;
634 // @phan-suppress-next-line PhanImpossibleCondition
635 if ( $param['useMaster'] ) {
636 $pager->mDb
= $services->getConnectionProvider()->getPrimaryDatabase();
638 // @phan-suppress-next-line PhanImpossibleCondition
639 if ( isset( $param['offset'] ) ) { # Tell pager to ignore WebRequest offset
640 $pager->setOffset( $param['offset'] );
643 // @phan-suppress-next-line PhanSuspiciousValueComparison
645 $pager->mLimit
= $lim;
647 // Fetch the log rows and build the HTML if needed
648 $logBody = $pager->getBody();
649 $numRows = $pager->getNumRows();
655 // @phan-suppress-next-line PhanParamTooFewUnpack Non-emptiness checked above
656 $msg = $context->msg( ...$msgKey );
657 if ( $page instanceof PageReference
) {
660 $s .= $msg->parseAsBlock();
662 $s .= $loglist->beginLogEventsList() .
664 $loglist->endLogEventsList();
665 // add styles for change tags
666 $context->getOutput()->addModuleStyles( 'mediawiki.interface.helpers.styles' );
667 // @phan-suppress-next-line PhanRedundantCondition
668 } elseif ( $showIfEmpty ) {
669 $s = Html
::rawElement( 'div', [ 'class' => 'mw-warning-logempty' ],
670 $context->msg( 'logempty' )->parse() );
673 if ( $page instanceof PageReference
) {
674 $titleFormatter = MediaWikiServices
::getInstance()->getTitleFormatter();
675 $pageName = $titleFormatter->getPrefixedDBkey( $page );
676 } elseif ( $page != '' ) {
682 if ( $numRows > $pager->mLimit
) { # Show "Full log" link
685 $urlParam['page'] = $pageName;
689 $urlParam['user'] = $user;
692 if ( !is_array( $types ) ) { # Make it an array, if it isn't
696 # If there is exactly one log type, we can link to Special:Log?type=foo
697 if ( count( $types ) == 1 ) {
698 $urlParam['type'] = $types[0];
701 // @phan-suppress-next-line PhanSuspiciousValueComparison
702 if ( $extraUrlParams !== false ) {
703 $urlParam = array_merge( $urlParam, $extraUrlParams );
706 $s .= $linkRenderer->makeKnownLink(
707 SpecialPage
::getTitleFor( 'Log' ),
708 $context->msg( 'log-fulllog' )->text(),
714 if ( $logBody && $msgKey[0] ) {
715 // TODO: The condition above is weird. Should this be done in any other cases?
716 // Or is it always true in practice?
718 // Mark as interface language (T60685)
719 $dir = $context->getLanguage()->getDir();
720 $lang = $context->getLanguage()->getHtmlCode();
721 $s = Html
::rawElement( 'div', [
722 'class' => "mw-content-$dir",
727 // Wrap in warning box
728 $s = Html
::warningBox(
730 'mw-warning-with-logexcerpt'
732 // Add styles for warning box
733 $context->getOutput()->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
736 // @phan-suppress-next-line PhanSuspiciousValueComparison, PhanRedundantCondition
737 if ( $wrap != '' ) { // Wrap message in html
738 $s = str_replace( '$1', $s, $wrap );
741 /* hook can return false, if we don't want the message to be emitted (Wikia BugId:7093) */
742 $hookRunner = new HookRunner( $services->getHookContainer() );
743 if ( $hookRunner->onLogEventsListShowLogExtract( $s, $types, $pageName, $user, $param ) ) {
744 // $out can be either an OutputPage object or a String-by-reference
745 if ( $out instanceof OutputPage
) {
756 * SQL clause to skip forbidden log types for this user
758 * @param \Wikimedia\Rdbms\IReadableDatabase $db
759 * @param string $audience Public/user
760 * @param Authority|null $performer User to check, required when audience isn't public
761 * @return string|false String on success, false on failure.
762 * @throws InvalidArgumentException
764 public static function getExcludeClause( $db, $audience = 'public', ?Authority
$performer = null ) {
765 $logRestrictions = MediaWikiServices
::getInstance()->getMainConfig()->get( MainConfigNames
::LogRestrictions
);
767 if ( $audience != 'public' && $performer === null ) {
768 throw new InvalidArgumentException(
769 'A User object must be given when checking for a user audience.'
773 // Reset the array, clears extra "where" clauses when $par is used
776 // Don't show private logs to unprivileged users
777 foreach ( $logRestrictions as $logType => $right ) {
778 if ( $audience == 'public' ||
!$performer->isAllowed( $right ) ) {
779 $hiddenLogs[] = $logType;
782 if ( count( $hiddenLogs ) == 1 ) {
783 return 'log_type != ' . $db->addQuotes( $hiddenLogs[0] );
784 } elseif ( $hiddenLogs ) {
785 return 'log_type NOT IN (' . $db->makeList( $hiddenLogs ) . ')';