Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
44.67% covered (danger)
44.67%
155 / 347
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
BlockListPager
44.80% covered (danger)
44.80%
155 / 346
0.00% covered (danger)
0.00%
0 / 11
869.88
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 getFieldNames
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 formatValue
72.81% covered (warning)
72.81%
83 / 114
0.00% covered (danger)
0.00%
0 / 1
35.58
 formatTarget
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 getRestrictionListHTML
78.69% covered (warning)
78.69%
48 / 61
0.00% covered (danger)
0.00%
0 / 1
14.64
 getQueryInfo
0.00% covered (danger)
0.00%
0 / 83
0.00% covered (danger)
0.00%
0 / 1
20
 getTableClass
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getIndexField
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getDefaultSort
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isFieldSortable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 preprocessResults
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
10
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 * @ingroup Pager
20 */
21
22namespace MediaWiki\Pager;
23
24use MediaWiki\Block\Block;
25use MediaWiki\Block\BlockActionInfo;
26use MediaWiki\Block\BlockRestrictionStore;
27use MediaWiki\Block\BlockUtils;
28use MediaWiki\Block\HideUserUtils;
29use MediaWiki\Block\Restriction\ActionRestriction;
30use MediaWiki\Block\Restriction\NamespaceRestriction;
31use MediaWiki\Block\Restriction\PageRestriction;
32use MediaWiki\Block\Restriction\Restriction;
33use MediaWiki\Cache\LinkBatchFactory;
34use MediaWiki\CommentFormatter\RowCommentFormatter;
35use MediaWiki\CommentStore\CommentStore;
36use MediaWiki\Config\ConfigException;
37use MediaWiki\Context\IContextSource;
38use MediaWiki\Html\Html;
39use MediaWiki\Linker\Linker;
40use MediaWiki\Linker\LinkRenderer;
41use MediaWiki\MainConfigNames;
42use MediaWiki\SpecialPage\SpecialPageFactory;
43use MediaWiki\User\UserIdentity;
44use MediaWiki\Utils\MWTimestamp;
45use stdClass;
46use Wikimedia\Rdbms\IConnectionProvider;
47use Wikimedia\Rdbms\IResultWrapper;
48
49/**
50 * @ingroup Pager
51 */
52class BlockListPager extends TablePager {
53
54    protected $conds;
55
56    /**
57     * Array of restrictions.
58     *
59     * @var Restriction[]
60     */
61    protected $restrictions = [];
62
63    private BlockActionInfo $blockActionInfo;
64    private BlockRestrictionStore $blockRestrictionStore;
65    private BlockUtils $blockUtils;
66    private HideUserUtils $hideUserUtils;
67    private CommentStore $commentStore;
68    private LinkBatchFactory $linkBatchFactory;
69    private RowCommentFormatter $rowCommentFormatter;
70    private SpecialPageFactory $specialPageFactory;
71
72    /** @var string[] */
73    private $formattedComments = [];
74
75    /** @var int */
76    private $readStage;
77
78    /**
79     * @param IContextSource $context
80     * @param BlockActionInfo $blockActionInfo
81     * @param BlockRestrictionStore $blockRestrictionStore
82     * @param BlockUtils $blockUtils
83     * @param HideUserUtils $hideUserUtils
84     * @param CommentStore $commentStore
85     * @param LinkBatchFactory $linkBatchFactory
86     * @param LinkRenderer $linkRenderer
87     * @param IConnectionProvider $dbProvider
88     * @param RowCommentFormatter $rowCommentFormatter
89     * @param SpecialPageFactory $specialPageFactory
90     * @param array $conds
91     */
92    public function __construct(
93        IContextSource $context,
94        BlockActionInfo $blockActionInfo,
95        BlockRestrictionStore $blockRestrictionStore,
96        BlockUtils $blockUtils,
97        HideUserUtils $hideUserUtils,
98        CommentStore $commentStore,
99        LinkBatchFactory $linkBatchFactory,
100        LinkRenderer $linkRenderer,
101        IConnectionProvider $dbProvider,
102        RowCommentFormatter $rowCommentFormatter,
103        SpecialPageFactory $specialPageFactory,
104        $conds
105    ) {
106        // Set database before parent constructor to avoid setting it there
107        $this->mDb = $dbProvider->getReplicaDatabase();
108        $this->readStage = $this->getConfig()->get( MainConfigNames::BlockTargetMigrationStage )
109            & SCHEMA_COMPAT_READ_MASK;
110        if ( $this->readStage !== SCHEMA_COMPAT_READ_OLD
111            && $this->readStage !== SCHEMA_COMPAT_READ_NEW
112        ) {
113            throw new ConfigException(
114                '$wgBlockTargetMigrationStage has an invalid read stage' );
115        }
116
117        parent::__construct( $context, $linkRenderer );
118
119        $this->blockActionInfo = $blockActionInfo;
120        $this->blockRestrictionStore = $blockRestrictionStore;
121        $this->blockUtils = $blockUtils;
122        $this->hideUserUtils = $hideUserUtils;
123        $this->commentStore = $commentStore;
124        $this->linkBatchFactory = $linkBatchFactory;
125        $this->rowCommentFormatter = $rowCommentFormatter;
126        $this->specialPageFactory = $specialPageFactory;
127        $this->conds = $conds;
128        $this->mDefaultDirection = IndexPager::DIR_DESCENDING;
129    }
130
131    protected function getFieldNames() {
132        static $headers = null;
133
134        if ( $headers === null ) {
135            $headers = [
136                'bl_timestamp' => 'blocklist-timestamp',
137                'target' => 'blocklist-target',
138                'bl_expiry' => 'blocklist-expiry',
139                'by' => 'blocklist-by',
140                'params' => 'blocklist-params',
141                'bl_reason' => 'blocklist-reason',
142            ];
143            foreach ( $headers as $key => $val ) {
144                $headers[$key] = $this->msg( $val )->text();
145            }
146        }
147
148        return $headers;
149    }
150
151    /**
152     * @param string $name
153     * @param string|null $value
154     * @return string
155     * @suppress PhanTypeArraySuspicious
156     */
157    public function formatValue( $name, $value ) {
158        static $msg = null;
159        if ( $msg === null ) {
160            $keys = [
161                'anononlyblock',
162                'createaccountblock',
163                'noautoblockblock',
164                'emailblock',
165                'blocklist-nousertalk',
166                'unblocklink',
167                'change-blocklink',
168                'blocklist-editing',
169                'blocklist-editing-sitewide',
170                'blocklist-hidden-param',
171            ];
172
173            foreach ( $keys as $key ) {
174                $msg[$key] = $this->msg( $key )->text();
175            }
176        }
177        '@phan-var string[] $msg';
178
179        /** @var stdClass $row */
180        $row = $this->mCurrentRow;
181
182        $language = $this->getLanguage();
183
184        $formatted = '';
185
186        $linkRenderer = $this->getLinkRenderer();
187
188        switch ( $name ) {
189            case 'bl_timestamp':
190                $formatted = htmlspecialchars( $language->userTimeAndDate( $value, $this->getUser() ) );
191                break;
192
193            case 'target':
194                $formatted = $this->formatTarget( $row );
195                break;
196
197            case 'bl_expiry':
198                $formatted = htmlspecialchars( $language->formatExpiry(
199                    $value,
200                    /* User preference timezone */true,
201                    'infinity',
202                    $this->getUser()
203                ) );
204                if ( $this->getAuthority()->isAllowed( 'block' ) ) {
205                    $links = [];
206                    if ( $row->bt_auto ) {
207                        $links[] = $linkRenderer->makeKnownLink(
208                            $this->specialPageFactory->getTitleForAlias( 'Unblock' ),
209                            $msg['unblocklink'],
210                            [],
211                            [ 'wpTarget' => "#{$row->bl_id}" ]
212                        );
213                    } else {
214                        $target = $row->bt_address ?? $row->bt_user_text;
215                        $links[] = $linkRenderer->makeKnownLink(
216                            $this->specialPageFactory->getTitleForAlias( "Unblock/$target" ),
217                            $msg['unblocklink']
218                        );
219                        $links[] = $linkRenderer->makeKnownLink(
220                            $this->specialPageFactory->getTitleForAlias( "Block/$target" ),
221                            $msg['change-blocklink']
222                        );
223                    }
224                    $formatted .= ' ' . Html::rawElement(
225                        'span',
226                        [ 'class' => 'mw-blocklist-actions' ],
227                        $this->msg( 'parentheses' )->rawParams(
228                            $language->pipeList( $links ) )->escaped()
229                    );
230                }
231                if ( $value !== 'infinity' ) {
232                    $timestamp = new MWTimestamp( $value );
233                    $formatted .= '<br />' . $this->msg(
234                        'ipb-blocklist-duration-left',
235                        $language->formatDuration(
236                            (int)$timestamp->getTimestamp( TS_UNIX ) - MWTimestamp::time(),
237                            // reasonable output
238                            [
239                                'minutes',
240                                'hours',
241                                'days',
242                                'years',
243                            ]
244                        )
245                    )->escaped();
246                }
247                break;
248
249            case 'by':
250                $formatted = Linker::userLink( (int)$value, $row->bl_by_text );
251                $formatted .= Linker::userToolLinks( (int)$value, $row->bl_by_text );
252                break;
253
254            case 'bl_reason':
255                $formatted = $this->formattedComments[$this->getResultOffset()];
256                break;
257
258            case 'params':
259                $properties = [];
260
261                if ( $row->bl_deleted ) {
262                    $properties[] = htmlspecialchars( $msg['blocklist-hidden-param' ] );
263                }
264                if ( $row->bl_sitewide ) {
265                    $properties[] = htmlspecialchars( $msg['blocklist-editing-sitewide'] );
266                }
267
268                if ( !$row->bl_sitewide && $this->restrictions ) {
269                    $list = $this->getRestrictionListHTML( $row );
270                    if ( $list ) {
271                        $properties[] = htmlspecialchars( $msg['blocklist-editing'] ) . $list;
272                    }
273                }
274
275                if ( $row->bl_anon_only ) {
276                    $properties[] = htmlspecialchars( $msg['anononlyblock'] );
277                }
278                if ( $row->bl_create_account ) {
279                    $properties[] = htmlspecialchars( $msg['createaccountblock'] );
280                }
281                if ( $row->bt_user && !$row->bl_enable_autoblock ) {
282                    $properties[] = htmlspecialchars( $msg['noautoblockblock'] );
283                }
284
285                if ( $row->bl_block_email ) {
286                    $properties[] = htmlspecialchars( $msg['emailblock'] );
287                }
288
289                if ( !$row->bl_allow_usertalk ) {
290                    $properties[] = htmlspecialchars( $msg['blocklist-nousertalk'] );
291                }
292
293                $formatted = Html::rawElement(
294                    'ul',
295                    [],
296                    implode( '', array_map( static function ( $prop ) {
297                        return Html::rawElement(
298                            'li',
299                            [],
300                            $prop
301                        );
302                    }, $properties ) )
303                );
304                break;
305
306            default:
307                $formatted = "Unable to format $name";
308                break;
309        }
310
311        return $formatted;
312    }
313
314    /**
315     * Format the target field
316     * @param stdClass $row
317     * @return string
318     */
319    private function formatTarget( $row ) {
320        if ( $row->bt_auto ) {
321            return $this->msg( 'autoblockid', $row->bl_id )->parse();
322        }
323
324        [ $target, $type ] = $this->blockUtils->parseBlockTargetRow( $row );
325
326        if ( $type === Block::TYPE_RANGE ) {
327            $userId = 0;
328            $userName = $target;
329        } elseif ( ( $row->hu_deleted ?? null )
330            && !$this->getAuthority()->isAllowed( 'hideuser' )
331        ) {
332            return Html::element(
333                'span',
334                [ 'class' => 'mw-blocklist-hidden' ],
335                $this->msg( 'blocklist-hidden-placeholder' )->text()
336            );
337        } elseif ( $target instanceof UserIdentity ) {
338            $userId = $target->getId();
339            $userName = $target->getName();
340        } elseif ( is_string( $target ) ) {
341            return htmlspecialchars( $target );
342        } else {
343            return $this->msg( 'empty-username' )->escaped();
344        }
345        return Linker::userLink( $userId, $userName ) .
346            Linker::userToolLinks(
347                $userId,
348                $userName,
349                false,
350                Linker::TOOL_LINKS_NOBLOCK
351            );
352    }
353
354    /**
355     * Get Restriction List HTML
356     *
357     * @param stdClass $row
358     *
359     * @return string
360     */
361    private function getRestrictionListHTML( stdClass $row ) {
362        $items = [];
363        $linkRenderer = $this->getLinkRenderer();
364
365        foreach ( $this->restrictions as $restriction ) {
366            if ( $restriction->getBlockId() !== (int)$row->bl_id ) {
367                continue;
368            }
369
370            switch ( $restriction->getType() ) {
371                case PageRestriction::TYPE:
372                    '@phan-var PageRestriction $restriction';
373                    if ( $restriction->getTitle() ) {
374                        $items[$restriction->getType()][] = Html::rawElement(
375                            'li',
376                            [],
377                            $linkRenderer->makeLink( $restriction->getTitle() )
378                        );
379                    }
380                    break;
381                case NamespaceRestriction::TYPE:
382                    $text = $restriction->getValue() === NS_MAIN
383                        ? $this->msg( 'blanknamespace' )->text()
384                        : $this->getLanguage()->getFormattedNsText(
385                            $restriction->getValue()
386                        );
387                    if ( $text ) {
388                        $items[$restriction->getType()][] = Html::rawElement(
389                            'li',
390                            [],
391                            $linkRenderer->makeLink(
392                                $this->specialPageFactory->getTitleForAlias( 'Allpages' ),
393                                $text,
394                                [],
395                                [
396                                    'namespace' => $restriction->getValue()
397                                ]
398                            )
399                        );
400                    }
401                    break;
402                case ActionRestriction::TYPE:
403                    $actionName = $this->blockActionInfo->getActionFromId( $restriction->getValue() );
404                    $enablePartialActionBlocks =
405                        $this->getConfig()->get( MainConfigNames::EnablePartialActionBlocks );
406                    if ( $actionName && $enablePartialActionBlocks ) {
407                        $items[$restriction->getType()][] = Html::rawElement(
408                            'li',
409                            [],
410                            $this->msg( 'ipb-action-' .
411                                $this->blockActionInfo->getActionFromId( $restriction->getValue() ) )->escaped()
412                        );
413                    }
414                    break;
415            }
416        }
417
418        if ( !$items ) {
419            return '';
420        }
421
422        $sets = [];
423        foreach ( $items as $key => $value ) {
424            $sets[] = Html::rawElement(
425                'li',
426                [],
427                $this->msg( 'blocklist-editing-' . $key ) . Html::rawElement(
428                    'ul',
429                    [],
430                    implode( '', $value )
431                )
432            );
433        }
434
435        return Html::rawElement(
436            'ul',
437            [],
438            implode( '', $sets )
439        );
440    }
441
442    public function getQueryInfo() {
443        $db = $this->getDatabase();
444        if ( $this->readStage === SCHEMA_COMPAT_READ_OLD ) {
445            $commentQuery = $this->commentStore->getJoin( 'ipb_reason' );
446            $info = [
447                'tables' => array_merge(
448                    [ 'ipblocks', 'ipblocks_by_actor' => 'actor' ],
449                    $commentQuery['tables']
450                ),
451                'fields' => [
452                    'bt_address' => 'ipb_address',
453                    'bt_user_text' => 'ipb_address',
454                    'bt_user' => 'ipb_user',
455                    'bt_auto' => 'ipb_auto',
456                    'bt_range_start' => 'ipb_range_start',
457                    'bt_range_end' => 'ipb_range_end',
458                    'bl_id' => 'ipb_id',
459                    'bl_by' => 'ipblocks_by_actor.actor_user',
460                    'bl_by_text' => 'ipblocks_by_actor.actor_name',
461                    'bl_timestamp' => 'ipb_timestamp',
462                    'bl_anon_only' => 'ipb_anon_only',
463                    'bl_create_account' => 'ipb_create_account',
464                    'bl_enable_autoblock' => 'ipb_enable_autoblock',
465                    'bl_expiry' => 'ipb_expiry',
466                    'bl_deleted' => 'ipb_deleted',
467                    'bl_block_email' => 'ipb_block_email',
468                    'bl_allow_usertalk' => 'ipb_allow_usertalk',
469                    'bl_sitewide' => 'ipb_sitewide',
470                    'bl_reason_text' => $commentQuery['fields']['ipb_reason_text'],
471                    'bl_reason_data' => $commentQuery['fields']['ipb_reason_data'],
472                    'bl_reason_cid' => $commentQuery['fields']['ipb_reason_cid'],
473                    // Aliases for IndexPager::extractResultInfo()
474                    'ipb_id',
475                    'ipb_timestamp',
476                ] + $commentQuery['fields'],
477                'conds' => $this->conds,
478                'join_conds' => [
479                    'ipblocks_by_actor' => [ 'JOIN', 'actor_id=ipb_by_actor' ]
480                ] + $commentQuery['joins']
481            ];
482            # Filter out any expired blocks
483            $info['conds'][] = $db->expr( 'ipb_expiry', '>', $db->timestamp() );
484
485            # Is the user allowed to see hidden blocks?
486            if ( !$this->getAuthority()->isAllowed( 'hideuser' ) ) {
487                $info['conds']['ipb_deleted'] = 0;
488            }
489        } else {
490            $commentQuery = $this->commentStore->getJoin( 'bl_reason' );
491            $info = [
492                'tables' => array_merge(
493                    [
494                        'block',
495                        'block_by_actor' => 'actor',
496                        'block_target',
497                    ],
498                    $commentQuery['tables']
499                ),
500                'fields' => [
501                    // The target fields should be those accepted by BlockUtils::parseBlockTargetRow()
502                    'bt_address',
503                    'bt_user_text',
504                    'bt_user',
505                    'bt_auto',
506                    'bt_range_start',
507                    'bt_range_end',
508                    // Block fields and aliases
509                    'bl_id',
510                    'bl_by' => 'block_by_actor.actor_user',
511                    'bl_by_text' => 'block_by_actor.actor_name',
512                    'bl_timestamp',
513                    'bl_anon_only',
514                    'bl_create_account',
515                    'bl_enable_autoblock',
516                    'bl_expiry',
517                    'bl_deleted',
518                    'bl_block_email',
519                    'bl_allow_usertalk',
520                    'bl_sitewide',
521                ] + $commentQuery['fields'],
522                'conds' => $this->conds,
523                'join_conds' => [
524                    'block_by_actor' => [ 'JOIN', 'actor_id=bl_by_actor' ],
525                    'block_target' => [ 'JOIN', 'bt_id=bl_target' ],
526                ] + $commentQuery['joins']
527            ];
528
529            # Filter out any expired blocks
530            $info['conds'][] = $db->expr( 'bl_expiry', '>', $db->timestamp() );
531
532            # Filter out blocks with the deleted option if the user doesn't
533            # have permission to see hidden users
534            # TODO: consider removing this -- we could just redact them instead.
535            # The mere fact that an admin has deleted a user does not need to
536            # be private and could be included in block lists and logs for
537            # transparency purposes. Previously, filtering out deleted blocks
538            # was a convenient way to avoid showing the target name.
539            if ( !$this->getAuthority()->isAllowed( 'hideuser' ) ) {
540                $info['conds']['bl_deleted'] = 0;
541            }
542
543            # Determine if the user is hidden
544            # With multiblocks we can't just rely on bl_deleted in the row being formatted
545            $info['fields']['hu_deleted'] = $this->hideUserUtils->getExpression(
546                $db, 'block_target.bt_user', HideUserUtils::HIDDEN_USERS );
547        }
548        return $info;
549    }
550
551    protected function getTableClass() {
552        return parent::getTableClass() . ' mw-blocklist';
553    }
554
555    public function getIndexField() {
556        if ( $this->readStage === SCHEMA_COMPAT_READ_OLD ) {
557            return [ [ 'ipb_timestamp', 'ipb_id' ] ];
558        } else {
559            return [ [ 'bl_timestamp', 'bl_id' ] ];
560        }
561    }
562
563    public function getDefaultSort() {
564        return '';
565    }
566
567    protected function isFieldSortable( $name ) {
568        return false;
569    }
570
571    /**
572     * Do a LinkBatch query to minimise database load when generating all these links
573     * @param IResultWrapper $result
574     */
575    public function preprocessResults( $result ) {
576        // Do a link batch query
577        $lb = $this->linkBatchFactory->newLinkBatch();
578        $lb->setCaller( __METHOD__ );
579
580        $partialBlocks = [];
581        $userIds = [];
582        foreach ( $result as $row ) {
583            $target = $row->bt_address ?? $row->bt_user_text;
584            if ( $target !== null ) {
585                $lb->add( NS_USER, $target );
586                $lb->add( NS_USER_TALK, $target );
587            }
588
589            if ( isset( $row->bl_by_text ) ) {
590                $lb->add( NS_USER, $row->bl_by_text );
591                $lb->add( NS_USER_TALK, $row->bl_by_text );
592            }
593
594            if ( !$row->bl_sitewide ) {
595                $partialBlocks[] = (int)$row->bl_id;
596            }
597
598            if ( $row->bt_user ) {
599                $userIds[] = $row->bt_user;
600            }
601        }
602
603        if ( $partialBlocks ) {
604            // Mutations to the $row object are not persisted. The restrictions will
605            // need be stored in a separate store.
606            $this->restrictions = $this->blockRestrictionStore->loadByBlockId( $partialBlocks );
607
608            foreach ( $this->restrictions as $restriction ) {
609                if ( $restriction->getType() === PageRestriction::TYPE ) {
610                    '@phan-var PageRestriction $restriction';
611                    $title = $restriction->getTitle();
612                    if ( $title ) {
613                        $lb->addObj( $title );
614                    }
615                }
616            }
617        }
618
619        $lb->execute();
620
621        // Format comments
622        // The keys of formattedComments will be the corresponding offset into $result
623        $this->formattedComments = $this->rowCommentFormatter->formatRows( $result, 'bl_reason' );
624    }
625
626}
627
628/**
629 * Retain the old class name for backwards compatibility.
630 * @deprecated since 1.41
631 */
632class_alias( BlockListPager::class, 'BlockListPager' );