Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
15.93% covered (danger)
15.93%
29 / 182
16.67% covered (danger)
16.67%
2 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
CheckUserLogPager
15.93% covered (danger)
15.93%
29 / 182
16.67% covered (danger)
16.67%
2 / 12
942.63
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 generateTimestampLink
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
30
 formatRow
0.00% covered (danger)
0.00%
0 / 75
0.00% covered (danger)
0.00%
0 / 1
72
 getStartBody
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getEndBody
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getEmptyBody
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getQueryInfo
70.00% covered (warning)
70.00%
21 / 30
0.00% covered (danger)
0.00%
0 / 1
5.68
 getIndexField
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 selectFields
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 preprocessResults
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 getPerformerSearchConds
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getQueryInfoForReasonSearch
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\CheckUser\CheckUser\Pagers;
4
5use IContextSource;
6use MediaWiki\Cache\LinkBatchFactory;
7use MediaWiki\CheckUser\Services\CheckUserLogService;
8use MediaWiki\CommentFormatter\CommentFormatter;
9use MediaWiki\CommentStore\CommentStore;
10use MediaWiki\Html\Html;
11use MediaWiki\Linker\Linker;
12use MediaWiki\Pager\RangeChronologicalPager;
13use MediaWiki\SpecialPage\SpecialPage;
14use MediaWiki\User\ActorStore;
15use MediaWiki\User\UserFactory;
16use MediaWiki\User\UserIdentityValue;
17use Wikimedia\IPUtils;
18use Wikimedia\Rdbms\IResultWrapper;
19
20class CheckUserLogPager extends RangeChronologicalPager {
21
22    /** @var array The options provided to the CheckUserLog form. May be empty. */
23    private array $opts;
24
25    private LinkBatchFactory $linkBatchFactory;
26    private CommentFormatter $commentFormatter;
27    private CheckUserLogService $checkUserLogService;
28    private CommentStore $commentStore;
29    private UserFactory $userFactory;
30    private ActorStore $actorStore;
31
32    /**
33     * @param IContextSource $context
34     * @param array $opts A array of keys that can include 'target', 'initiator', 'start', 'end'
35     *         'year' and 'month'. Target should be a user, IP address or IP range. Initiator should be a user.
36     *         Start and end should be timestamps. Year and month are converted to end but ignored if end is
37     *         provided.
38     * @param LinkBatchFactory $linkBatchFactory
39     * @param CommentStore $commentStore
40     * @param CommentFormatter $commentFormatter
41     * @param CheckUserLogService $checkUserLogService
42     * @param UserFactory $userFactory
43     * @param ActorStore $actorStore
44     */
45    public function __construct(
46        IContextSource $context,
47        array $opts,
48        LinkBatchFactory $linkBatchFactory,
49        CommentStore $commentStore,
50        CommentFormatter $commentFormatter,
51        CheckUserLogService $checkUserLogService,
52        UserFactory $userFactory,
53        ActorStore $actorStore
54    ) {
55        parent::__construct( $context );
56        $this->linkBatchFactory = $linkBatchFactory;
57        $this->commentStore = $commentStore;
58        $this->commentFormatter = $commentFormatter;
59        $this->checkUserLogService = $checkUserLogService;
60        $this->userFactory = $userFactory;
61        $this->actorStore = $actorStore;
62        $this->opts = $opts;
63
64        // Date filtering: use timestamp if available - From SpecialContributions.php
65        $startTimestamp = '';
66        $endTimestamp = '';
67        if ( isset( $opts['start'] ) && $opts['start'] ) {
68            $startTimestamp = $opts['start'] . ' 00:00:00';
69        }
70        if ( isset( $opts['end'] ) && $opts['end'] ) {
71            $endTimestamp = $opts['end'] . ' 23:59:59';
72        }
73        $this->getDateRangeCond( $startTimestamp, $endTimestamp );
74    }
75
76    /**
77     * If appropriate, generate a link that wraps around the provided date, time, or
78     * date and time. The date and time is escaped by this function.
79     *
80     * @param string $dateAndTime The string representation of the date, time or date and time.
81     * @param array|\stdClass $row The current row being formatted in formatRow().
82     * @return string|null The date and time wrapped in a link if appropriate.
83     */
84    protected function generateTimestampLink( string $dateAndTime, $row ) {
85        $highlight = $this->getRequest()->getVal( 'highlight' );
86        // Add appropriate classes to the date and time.
87        $dateAndTimeClasses = [];
88        if (
89            $highlight === strval( $row->cul_timestamp )
90        ) {
91            $dateAndTimeClasses[] = 'mw-checkuser-log-highlight-entry';
92        }
93        // If the CU log search has a specified target or initiator then
94        // provide a link to this log entry without the current filtering
95        // for these values.
96        if (
97            $this->opts['target'] ||
98            $this->opts['initiator']
99        ) {
100            return $this->getLinkRenderer()->makeLink(
101                SpecialPage::getTitleFor( 'CheckUserLog' ),
102                $dateAndTime,
103                [
104                    'class' => $dateAndTimeClasses,
105                ],
106                [
107                    // offset is used by IndexPager, it does not know this is a timestamp,
108                    // so provide in database format to make it working as string there.
109                    'offset' => $this->getDatabase()->timestamp(
110                        (int)wfTimestamp( TS_UNIX, $row->cul_timestamp ) + 3600 ),
111                    'highlight' => $row->cul_timestamp,
112                ]
113            );
114        } elseif ( $dateAndTimeClasses ) {
115            return Html::element(
116                'span',
117                [ 'class' => $dateAndTimeClasses ],
118                $dateAndTime
119            );
120        } else {
121            return htmlspecialchars( $dateAndTime );
122        }
123    }
124
125    /**
126     * @inheritDoc
127     */
128    public function formatRow( $row ) {
129        if ( $row->actor_user ) {
130            $performerHidden = $this->userFactory->newFromUserIdentity(
131                UserIdentityValue::newRegistered( $row->actor_user, $row->actor_name )
132            )->isHidden();
133        } else {
134            $performerHidden = $this->userFactory->newFromActorId( $row->actor_id )->isHidden();
135        }
136        if ( $performerHidden && !$this->getAuthority()->isAllowed( 'hideuser' ) ) {
137            // Performer of the check is hidden and the logged in user does not have
138            //  right to see hidden users.
139            $user = Html::element(
140                'span',
141                [ 'class' => 'history-deleted' ],
142                $this->msg( 'rev-deleted-user' )->text()
143            );
144        } else {
145            $user = Linker::userLink( $row->actor_user, $row->actor_name );
146            if ( $performerHidden ) {
147                // Performer is hidden, but current user has rights to see it.
148                // Mark the username has hidden by wrapping it in a history-deleted span.
149                $user = Html::rawElement(
150                    'span',
151                    [ 'class' => 'history-deleted' ],
152                    $user
153                );
154            }
155            $user .= $this->msg( 'word-separator' )->escaped()
156                . Html::rawElement( 'span', [ 'classes' => 'mw-usertoollinks' ],
157                    $this->msg( 'parentheses' )->rawParams( $this->getLinkRenderer()->makeLink(
158                        SpecialPage::getTitleFor( 'CheckUserLog' ),
159                        $this->msg( 'checkuser-log-checks-by' )->text(),
160                        [],
161                        [
162                            'cuInitiator' => $row->actor_name,
163                        ]
164                    ) )->escaped()
165                );
166        }
167
168        $targetHidden = $this->userFactory->newFromUserIdentity(
169            new UserIdentityValue( $row->cul_target_id, $row->cul_target_text )
170        )->isHidden();
171        if ( $targetHidden && !$this->getAuthority()->isAllowed( 'hideuser' ) ) {
172            // Target of the check is hidden and the logged in user does not have
173            //  right to see hidden users.
174            $target = Html::element(
175                'span',
176                [ 'class' => 'history-deleted' ],
177                $this->msg( 'rev-deleted-user' )->text()
178            );
179        } else {
180            $target = Linker::userLink( $row->cul_target_id, $row->cul_target_text );
181            if ( $targetHidden ) {
182                // Target is hidden, but current user has rights to see it.
183                // Mark the username has hidden by wrapping it in a history-deleted span.
184                $target = Html::rawElement(
185                    'span',
186                    [ 'class' => 'history-deleted' ],
187                    $target
188                );
189            }
190            $target .= Linker::userToolLinks( $row->cul_target_id, trim( $row->cul_target_text ) );
191        }
192
193        $lang = $this->getLanguage();
194        $contextUser = $this->getUser();
195        // The following messages are generated here:
196        // * checkuser-log-entry-userips
197        // * checkuser-log-entry-ipactions
198        // * checkuser-log-entry-ipusers
199        // * checkuser-log-entry-ipactions-xff
200        // * checkuser-log-entry-ipusers-xff
201        // * checkuser-log-entry-useractions
202        // * checkuser-log-entry-investigate
203        $cul_type = [
204            'ipedits' => 'ipactions',
205            'ipedits-xff' => 'ipactions-xff',
206            'useredits' => 'useractions'
207        ][$row->cul_type] ?? $row->cul_type;
208        $rowContent = $this->msg( 'checkuser-log-entry-' . $cul_type )
209            ->rawParams(
210                $user,
211                $target,
212                $this->generateTimestampLink(
213                    $lang->userTimeAndDate(
214                        wfTimestamp( TS_MW, $row->cul_timestamp ), $contextUser
215                    ),
216                    $row
217                ),
218                $this->generateTimestampLink(
219                    $lang->userDate( wfTimestamp( TS_MW, $row->cul_timestamp ), $contextUser ),
220                    $row
221                ),
222                $this->generateTimestampLink(
223                    $lang->userTime( wfTimestamp( TS_MW, $row->cul_timestamp ), $contextUser ),
224                    $row
225                )
226            )->parse();
227        $rowContent .= $this->commentFormatter->formatBlock(
228            $this->commentStore->getComment( 'cul_reason', $row )->text
229        );
230
231        $attribs = [
232            'data-mw-culogid' => $row->cul_id,
233        ];
234        return Html::rawElement( 'li', $attribs, $rowContent ) . "\n";
235    }
236
237    /**
238     * @return string
239     */
240    public function getStartBody() {
241        if ( $this->getNumRows() ) {
242            return '<ul>';
243        }
244
245        return '';
246    }
247
248    /**
249     * @return string
250     */
251    public function getEndBody() {
252        if ( $this->getNumRows() ) {
253            return '</ul>';
254        }
255
256        return '';
257    }
258
259    /**
260     * @return string
261     */
262    public function getEmptyBody() {
263        return '<p>' . $this->msg( 'checkuser-empty' )->escaped() . '</p>';
264    }
265
266    /** @inheritDoc */
267    public function getQueryInfo() {
268        $queryInfo = [
269            'tables' => [ 'cu_log', 'cu_log_actor' => 'actor' ],
270            'fields' => $this->selectFields(),
271            'conds' => [],
272            'join_conds' => [ 'cu_log_actor' => [ 'JOIN', [ 'actor_id = cul_actor' ] ] ],
273            'options' => [],
274        ];
275
276        $reasonCommentQuery = $this->commentStore->getJoin( 'cul_reason' );
277        $queryInfo['tables'] += $reasonCommentQuery['tables'];
278        $queryInfo['fields'] += $reasonCommentQuery['fields'];
279        $queryInfo['join_conds'] += $reasonCommentQuery['joins'];
280
281        if ( $this->opts['target'] !== '' ) {
282            $queryInfo['conds'] = array_merge(
283                $queryInfo['conds'],
284                $this->checkUserLogService->getTargetSearchConds( $this->opts['target'] ) ?? []
285            );
286            if ( IPUtils::isIPAddress( $this->opts['target'] ) ) {
287                // Use the cul_target_hex index on the query if the target is an IP
288                // otherwise the query could take a long time (T342639)
289                $queryInfo['options']['USE INDEX'] = [ 'cu_log' => 'cul_target_hex' ];
290            }
291        }
292
293        if ( $this->opts['initiator'] !== '' ) {
294            $queryInfo['conds'] = array_merge(
295                $queryInfo['conds'],
296                $this->getPerformerSearchConds( $this->opts['initiator'] ) ?? []
297            );
298        }
299
300        if ( $this->opts['reason'] !== '' ) {
301            $reasonSearchQuery = $this->getQueryInfoForReasonSearch( $this->opts['reason'] );
302            $queryInfo['tables'] += $reasonSearchQuery['tables'];
303            $queryInfo['fields'] += $reasonSearchQuery['fields'];
304            $queryInfo['conds'] = array_merge( $queryInfo['conds'], $reasonSearchQuery['conds'] );
305            $queryInfo['join_conds'] += $reasonSearchQuery['join_conds'];
306        }
307
308        return $queryInfo;
309    }
310
311    /**
312     * @inheritDoc
313     */
314    public function getIndexField() {
315        return 'cul_timestamp';
316    }
317
318    /**
319     * Gets the fields for a select on the cu_log table.
320     *
321     * @return string[]
322     */
323    public function selectFields(): array {
324        return [
325            'cul_id', 'cul_timestamp', 'cul_type', 'cul_target_id',
326            'cul_target_text', 'actor_name', 'actor_user', 'actor_id'
327        ];
328    }
329
330    /**
331     * Do a batch query for links' existence and add it to LinkCache
332     *
333     * @param IResultWrapper $result
334     */
335    protected function preprocessResults( $result ) {
336        if ( $this->getNumRows() === 0 ) {
337            return;
338        }
339
340        $lb = $this->linkBatchFactory->newLinkBatch();
341        $lb->setCaller( __METHOD__ );
342        foreach ( $result as $row ) {
343            // Performer
344            $lb->add( NS_USER, $row->actor_name );
345
346            if ( $row->cul_type == 'userips' || $row->cul_type == 'useredits' ) {
347                $lb->add( NS_USER, $row->cul_target_text );
348                $lb->add( NS_USER_TALK, $row->cul_target_text );
349            }
350        }
351        $lb->execute();
352        $result->seek( 0 );
353    }
354
355    /**
356     * Get DB search conditions for the initiator
357     *
358     * @param string $initiator the username of the initiator.
359     * @return array|null array if valid target, null if invalid
360     */
361    private function getPerformerSearchConds( string $initiator ): ?array {
362        $initiatorId = $this->actorStore->findActorIdByName( $initiator, $this->mDb ) ?? false;
363        if ( $initiatorId !== false ) {
364            return [ 'cul_actor' => $initiatorId ];
365        }
366        return null;
367    }
368
369    /**
370     * Get the query info for a reason search
371     *
372     * @param string $reason The reason to search for
373     * @return string[][] With three keys to arrays for tables, fields and joins.
374     */
375    public function getQueryInfoForReasonSearch( string $reason ): array {
376        $queryInfo = [ 'tables' => [], 'fields' => [], 'join_conds' => [] ];
377        $plaintextReason = $this->checkUserLogService->getPlaintextReason( $reason );
378
379        if ( $plaintextReason == '' ) {
380            return $queryInfo;
381        }
382
383        $plaintextReasonCommentQuery = $this->commentStore->getJoin( 'cul_reason_plaintext' );
384        $queryInfo['tables'] += $plaintextReasonCommentQuery['tables'];
385        $queryInfo['fields'] += $plaintextReasonCommentQuery['fields'];
386        $queryInfo['join_conds'] += $plaintextReasonCommentQuery['joins'];
387
388        $queryInfo['conds'] = [ 'comment_cul_reason_plaintext.comment_text' => $plaintextReason ];
389
390        return $queryInfo;
391    }
392}