Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.48% covered (warning)
78.48%
175 / 223
30.00% covered (danger)
30.00%
3 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialLog
78.83% covered (warning)
78.83%
175 / 222
30.00% covered (danger)
30.00%
3 / 10
83.71
0.00% covered (danger)
0.00%
0 / 1
 __construct
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 execute
96.05% covered (success)
96.05%
73 / 76
0.00% covered (danger)
0.00%
0 / 1
21
 normalizeUserPage
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
11.08
 getLogTypesOnUser
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 getSubpagesForPrefixSearch
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 parseParams
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 resolveLogType
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 show
92.45% covered (success)
92.45%
49 / 53
0.00% covered (danger)
0.00%
0 / 1
8.03
 getActionButtons
13.51% covered (danger)
13.51%
5 / 37
0.00% covered (danger)
0.00%
0 / 1
21.17
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Specials;
8
9use MediaWiki\ChangeTags\ChangeTags;
10use MediaWiki\Exception\PermissionsError;
11use MediaWiki\HookContainer\HookRunner;
12use MediaWiki\Html\FormOptions;
13use MediaWiki\Html\Html;
14use MediaWiki\Html\ListToggle;
15use MediaWiki\Logging\LogEventsList;
16use MediaWiki\Logging\LogFormatterFactory;
17use MediaWiki\Logging\LogPage;
18use MediaWiki\MainConfigNames;
19use MediaWiki\MediaWikiServices;
20use MediaWiki\Page\LinkBatchFactory;
21use MediaWiki\Pager\LogPager;
22use MediaWiki\SpecialPage\SpecialPage;
23use MediaWiki\Title\Title;
24use MediaWiki\User\ActorNormalization;
25use MediaWiki\User\TempUser\TempUserConfig;
26use MediaWiki\User\UserIdentityLookup;
27use MediaWiki\User\UserNameUtils;
28use MediaWiki\Utils\MWTimestamp;
29use Wikimedia\IPUtils;
30use Wikimedia\Rdbms\IConnectionProvider;
31use Wikimedia\Rdbms\IExpression;
32use Wikimedia\Timestamp\TimestampException;
33
34/**
35 * A special page that lists log entries
36 *
37 * @ingroup SpecialPage
38 */
39class SpecialLog extends SpecialPage {
40
41    private TempUserConfig $tempUserConfig;
42
43    public function __construct(
44        private readonly LinkBatchFactory $linkBatchFactory,
45        private readonly IConnectionProvider $dbProvider,
46        private readonly ActorNormalization $actorNormalization,
47        private readonly UserIdentityLookup $userIdentityLookup,
48        private readonly UserNameUtils $userNameUtils,
49        private readonly LogFormatterFactory $logFormatterFactory,
50        ?TempUserConfig $tempUserConfig = null
51    ) {
52        parent::__construct( 'Log' );
53        if ( $tempUserConfig instanceof TempUserConfig ) {
54            $this->tempUserConfig = $tempUserConfig;
55        } else {
56            $this->tempUserConfig = MediaWikiServices::getInstance()->getTempUserConfig();
57        }
58    }
59
60    /** @inheritDoc */
61    public function execute( $par ) {
62        $this->setHeaders();
63        $this->outputHeader();
64        $out = $this->getOutput();
65        $out->addModuleStyles( 'mediawiki.interface.helpers.styles' );
66        $this->addHelpLink( 'Help:Log' );
67
68        $opts = new FormOptions;
69        $opts->add( 'type', '' );
70        $opts->add( 'user', '' );
71        $opts->add( 'page', [] );
72        $opts->add( 'pattern', false );
73        $opts->add( 'year', null, FormOptions::INTNULL );
74        $opts->add( 'month', null, FormOptions::INTNULL );
75        $opts->add( 'day', null, FormOptions::INTNULL );
76        $opts->add( 'tagfilter', '' );
77        $opts->add( 'tagInvert', false );
78        $opts->add( 'offset', '' );
79        $opts->add( 'dir', '' );
80        $opts->add( 'offender', '' );
81        $opts->add( 'subtype', '' );
82        $opts->add( 'logid', '' );
83
84        // Set values
85        if ( $par !== null ) {
86            $this->parseParams( (string)$par );
87        }
88        $opts->fetchValuesFromRequest( $this->getRequest() );
89
90        // Set date values
91        $dateString = $this->getRequest()->getVal( 'wpdate' );
92        if ( $dateString ) {
93            try {
94                $dateStamp = MWTimestamp::getInstance( $dateString . ' 00:00:00' );
95            } catch ( TimestampException ) {
96                // If users provide an invalid date, silently ignore it
97                // instead of letting an exception bubble up (T201411)
98                $dateStamp = false;
99            }
100            if ( $dateStamp ) {
101                $opts->setValue( 'year', (int)$dateStamp->format( 'Y' ) );
102                $opts->setValue( 'month', (int)$dateStamp->format( 'm' ) );
103                $opts->setValue( 'day', (int)$dateStamp->format( 'd' ) );
104            }
105        }
106
107        // If the user doesn't have the right permission to view the specific
108        // log type, throw a PermissionsError
109        $logRestrictions = $this->getConfig()->get( MainConfigNames::LogRestrictions );
110        $type = $opts->getValue( 'type' );
111        if ( isset( $logRestrictions[$type] )
112            && !$this->getAuthority()->isAllowed( $logRestrictions[$type] )
113        ) {
114            throw new PermissionsError( $logRestrictions[$type] );
115        }
116
117        # TODO: Move this into LogPager like other query conditions.
118        # Handle type-specific inputs
119        $qc = [];
120        $offenderName = $opts->getValue( 'offender' );
121        if ( $opts->getValue( 'type' ) == 'suppress' && $offenderName !== '' ) {
122            $dbr = $this->dbProvider->getReplicaDatabase();
123            $offenderId = $this->actorNormalization->findActorIdByName( $offenderName, $dbr );
124            if ( $offenderId ) {
125                $qc = [ 'ls_field' => 'target_author_actor', 'ls_value' => strval( $offenderId ) ];
126            } else {
127                // Unknown offender, thus results have to be empty
128                $qc = [ '1=0' ];
129            }
130        } else {
131            if ( $this->tempUserConfig->isKnown() ) {
132                // See T398423
133                // Three cases possible:
134                // 1. Special:Log/newusers is loaded as-is with 'user' field empty or set to non-temp user.
135                // The checkbox will be shown checked by default but have no value and is expected to exclude
136                // temporary accounts.
137                // 2. Special:Log/newusers is loaded as-is with 'user' field set to a temp username.
138                // The checkbox will be shown unchecked and have no value. We expect not to exclude temporary accounts.
139                // 3. form submitted, exclude temp accounts
140                // 4. form submitted, include temp accounts
141                // Check for cases 1 and 3 and omit temporary accounts in the pager query.
142                $formWasSubmitted = $this->getRequest()->getVal( 'wpFormIdentifier' ) === 'logeventslist';
143                if (
144                    (
145                        !$formWasSubmitted &&
146                        !$this->getRequest()->getVal( 'excludetempacct' ) &&
147                        !$this->tempUserConfig->isTempName( $opts->getValue( 'user' ) )
148                    ) ||
149                    (
150                        $formWasSubmitted &&
151                        $this->getRequest()->getVal( 'excludetempacct' )
152                    )
153                ) {
154                    $dbr = $this->dbProvider->getReplicaDatabase();
155                    if ( $opts->getValue( 'type' ) === '' ) {
156                        // Support excluding temporary account creations on Special:Log
157                        $qc = [
158                            $dbr->expr( 'log_type', '!=', 'newusers' )->orExpr(
159                                $dbr->expr( 'log_type', '=', 'newusers' )
160                                    ->andExpr( $this->tempUserConfig
161                                    ->getMatchCondition( $dbr, 'logging_actor.actor_name', IExpression::NOT_LIKE ) )
162                            )
163                        ];
164                    } elseif ( $opts->getValue( 'type' ) === 'newusers' ) {
165                        $qc = [
166                            $this->tempUserConfig
167                                ->getMatchCondition( $dbr, 'logging_actor.actor_name', IExpression::NOT_LIKE )
168                        ];
169                    }
170                }
171            }
172
173            // Allow extensions to add relations to their search types
174            $this->getHookRunner()->onSpecialLogAddLogSearchRelations(
175                $opts->getValue( 'type' ), $this->getRequest(), $qc );
176        }
177
178        # TODO: Move this into LogEventList and use it as filter-callback in the field descriptor.
179        # Some log types are only for a 'User:' title but we might have been given
180        # only the username instead of the full title 'User:username'. This part try
181        # to lookup for a user by that name and eventually fix user input. See T3697.
182        if ( in_array( $opts->getValue( 'type' ), self::getLogTypesOnUser( $this->getHookRunner() ) ) ) {
183            $pages = [];
184            foreach ( $opts->getValue( 'page' ) as $page ) {
185                $page = $this->normalizeUserPage( $page );
186                if ( $page !== null ) {
187                    $pages[] = $page->getPrefixedText();
188                }
189            }
190            $opts->setValue( 'page', $pages );
191        }
192
193        $this->show( $opts, $qc );
194    }
195
196    /**
197     * Add the namespace prefix to a user page and validate it
198     *
199     * @param string $page
200     * @return Title|null
201     */
202    private function normalizeUserPage( $page ) {
203        $target = Title::newFromText( $page );
204        if ( $target && $target->getNamespace() === NS_MAIN ) {
205            if ( IPUtils::isValidRange( $target->getText() ) ) {
206                $page = IPUtils::sanitizeRange( $target->getText() );
207            }
208            # User forgot to add 'User:', we are adding it for them
209            $target = Title::makeTitleSafe( NS_USER, $page );
210        } elseif ( $target && $target->getNamespace() === NS_USER
211            && IPUtils::isValidRange( $target->getText() )
212        ) {
213            $ipOrRange = IPUtils::sanitizeRange( $target->getText() );
214            if ( $ipOrRange !== $target->getText() ) {
215                $target = Title::makeTitleSafe( NS_USER, $ipOrRange );
216            }
217        }
218        return $target;
219    }
220
221    /**
222     * List log type for which the target is a user
223     * Thus if the given target is in NS_MAIN we can alter it to be an NS_USER
224     * Title user instead.
225     *
226     * @since 1.25
227     * @since 1.36 Added $runner parameter
228     *
229     * @param HookRunner|null $runner
230     * @return array
231     */
232    public static function getLogTypesOnUser( ?HookRunner $runner = null ) {
233        static $types = null;
234        if ( $types !== null ) {
235            return $types;
236        }
237        $types = [
238            'block',
239            'newusers',
240            'rights',
241            'renameuser',
242        ];
243
244        ( $runner ?? new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )
245            ->onGetLogTypesOnUser( $types );
246        return $types;
247    }
248
249    /**
250     * Return an array of subpages that this special page will accept.
251     *
252     * @return string[] subpages
253     */
254    public function getSubpagesForPrefixSearch() {
255        $subpages = LogPage::validTypes();
256
257        // Mechanism allowing extensions to change the log types listed as
258        // search hints (T398293).
259        $this->getHookRunner()->onSpecialLogGetSubpagesForPrefixSearch(
260            $this->getContext(),
261            $subpages
262        );
263
264        $subpages[] = 'all';
265
266        // Remove duplicates just in case a hook provides the same subpage twice
267        $subpages = array_unique( $subpages );
268        sort( $subpages );
269        return $subpages;
270    }
271
272    /**
273     * Set options based on the subpage title parts:
274     * - One part that is a valid log type: Special:Log/logtype
275     * - Two parts: Special:Log/logtype/username
276     * - Otherwise, assume the whole subpage is a username.
277     *
278     * @param string $par
279     */
280    private function parseParams( string $par ) {
281        $params = explode( '/', $par, 2 );
282        $logType = $this->resolveLogType( $params );
283
284        if ( $logType ) {
285            $this->getRequest()->setVal( 'type', $logType );
286            if ( count( $params ) === 2 ) {
287                $this->getRequest()->setVal( 'user', $params[1] );
288            }
289        } elseif ( $par !== '' ) {
290            $this->getRequest()->setVal( 'user', $par );
291        }
292    }
293
294    /**
295     * Determines the requested log type based on the parameters from the
296     * requested URL, which are obtained by splitting the path by the slash
297     * character.
298     *
299     * This method returns the requested type, if one is provided and is '*',
300     * 'all', or is included in the values from LogPage::validTypes(); or an
301     * empty string otherwise.
302     *
303     * Extensions may modify the requested type by implementing the
304     * SpecialLogResolveLogType hook, which may be used to change the log type
305     * obtained from the URL and other request parameters.
306     *
307     * @param array $params Values resulting from splitting the URL by '/'.
308     * @return string The requested type if valid, or an empty string otherwise.
309     */
310    private function resolveLogType( array $params ): string {
311        // Mechanism for changing the parameters of Special:Log
312        // from extensions (T381875)
313        $logType = $params[0] ?? null;
314
315        $this->getHookRunner()->onSpecialLogResolveLogType(
316            $params,
317            $logType
318        );
319
320        if ( $logType !== '' ) {
321            $symsForAll = [ '*', 'all' ];
322            $allowedTypes = array_merge( LogPage::validTypes(), $symsForAll );
323
324            if ( in_array( $logType, $allowedTypes ) ) {
325                return $logType;
326            }
327        }
328
329        return '';
330    }
331
332    private function show( FormOptions $opts, array $extraConds ) {
333        # Create a LogPager item to get the results and a LogEventsList item to format them...
334        $loglist = new LogEventsList(
335            $this->getContext(),
336            $this->getLinkRenderer(),
337            LogEventsList::USE_CHECKBOXES
338        );
339        $pager = new LogPager(
340            $loglist,
341            $opts->getValue( 'type' ),
342            $opts->getValue( 'user' ),
343            $opts->getValue( 'page' ),
344            $opts->getValue( 'pattern' ),
345            $extraConds,
346            $opts->getValue( 'year' ),
347            $opts->getValue( 'month' ),
348            $opts->getValue( 'day' ),
349            $opts->getValue( 'tagfilter' ),
350            $opts->getValue( 'subtype' ),
351            $opts->getValue( 'logid' ),
352            $this->linkBatchFactory,
353            $this->actorNormalization,
354            $this->logFormatterFactory,
355            $opts->getValue( 'tagInvert' )
356        );
357
358        # Set relevant user
359        $performer = $pager->getPerformer();
360        if ( $performer ) {
361            $performerUser = $this->userIdentityLookup->getUserIdentityByName( $performer );
362            // Only set valid local user as the relevant user (T344886)
363            // Uses the same condition as the SpecialContributions class did
364            if ( $performerUser && !IPUtils::isValidRange( $performer ) &&
365                ( $this->userNameUtils->isIP( $performer ) || $performerUser->isRegistered() )
366            ) {
367                $this->getSkin()->setRelevantUser( $performerUser );
368            }
369        }
370
371        # Show form options
372        $succeed = $loglist->showOptions(
373            $opts->getValue( 'type' ),
374            $opts->getValue( 'year' ),
375            $opts->getValue( 'month' ),
376            $opts->getValue( 'day' ),
377            $opts->getValue( 'user' ),
378        );
379        if ( !$succeed ) {
380            return;
381        }
382
383        $this->getOutput()->setPageTitleMsg(
384            ( new LogPage( $opts->getValue( 'type' ) ) )->getName()
385        );
386
387        # Insert list
388        $logBody = $pager->getBody();
389        if ( $logBody ) {
390            $this->getOutput()->addHTML(
391                $pager->getNavigationBar() .
392                    $this->getActionButtons(
393                        $loglist->beginLogEventsList() .
394                            $logBody .
395                            $loglist->endLogEventsList()
396                    ) .
397                    $pager->getNavigationBar()
398            );
399        } else {
400            $this->getOutput()->addWikiMsg( 'logempty' );
401        }
402    }
403
404    private function getActionButtons( string $formcontents ): string {
405        $canRevDelete = $this->getAuthority()
406            ->isAllowedAll( 'deletedhistory', 'deletelogentry' );
407        $showTagEditUI = ChangeTags::showTagEditingUI( $this->getAuthority() );
408        # If the user doesn't have the ability to delete log entries nor edit tags,
409        # don't bother showing them the button(s).
410        if ( !$canRevDelete && !$showTagEditUI ) {
411            return $formcontents;
412        }
413
414        # Show button to hide log entries and/or edit change tags
415        $s = Html::openElement(
416            'form',
417            [ 'action' => wfScript(), 'id' => 'mw-log-deleterevision-submit' ]
418        ) . "\n";
419        $s .= Html::hidden( 'type', 'logging' ) . "\n";
420
421        $buttons = '';
422        if ( $canRevDelete ) {
423            $buttons .= Html::element(
424                'button',
425                [
426                    'type' => 'submit',
427                    'name' => 'title',
428                    'value' => SpecialPage::getTitleFor( 'Revisiondelete' )->getPrefixedDBkey(),
429                    'class' => "deleterevision-log-submit mw-log-deleterevision-button mw-ui-button"
430                ],
431                $this->msg( 'showhideselectedlogentries' )->text()
432            ) . "\n";
433        }
434        if ( $showTagEditUI ) {
435            $buttons .= Html::element(
436                'button',
437                [
438                    'type' => 'submit',
439                    'name' => 'title',
440                    'value' => SpecialPage::getTitleFor( 'EditTags' )->getPrefixedDBkey(),
441                    'class' => "editchangetags-log-submit mw-log-editchangetags-button mw-ui-button"
442                ],
443                $this->msg( 'log-edit-tags' )->text()
444            ) . "\n";
445        }
446
447        $buttons .= ( new ListToggle( $this->getOutput() ) )->getHTML();
448
449        $s .= $buttons . $formcontents . $buttons;
450        $s .= Html::closeElement( 'form' );
451
452        return $s;
453    }
454
455    /** @inheritDoc */
456    protected function getGroupName() {
457        return 'changes';
458    }
459}
460
461/** @deprecated class alias since 1.41 */
462class_alias( SpecialLog::class, 'SpecialLog' );