Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.21% covered (success)
95.21%
139 / 146
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbuseFilterHistoryPager
95.21% covered (success)
95.21%
139 / 146
77.78% covered (warning)
77.78%
7 / 9
52
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getFieldNames
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 formatValue
91.04% covered (success)
91.04%
61 / 67
0.00% covered (danger)
0.00%
0 / 1
22.35
 getQueryInfo
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
6.01
 reallyDoQuery
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
6
 preprocessResults
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getDefaultSort
n/a
0 / 0
n/a
0 / 0
1
 isFieldSortable
n/a
0 / 0
n/a
0 / 0
1
 getCellAttrs
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 getRowClass
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTitle
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\Pager;
4
5use MediaWiki\Context\IContextSource;
6use MediaWiki\Extension\AbuseFilter\AbuseFilter;
7use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
8use MediaWiki\Extension\AbuseFilter\Filter\Flags;
9use MediaWiki\Extension\AbuseFilter\FilterLookup;
10use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseFilter;
11use MediaWiki\Extension\AbuseFilter\SpecsFormatter;
12use MediaWiki\Html\Html;
13use MediaWiki\Linker\Linker;
14use MediaWiki\Linker\LinkRenderer;
15use MediaWiki\Page\LinkBatchFactory;
16use MediaWiki\Pager\TablePager;
17use MediaWiki\Title\Title;
18use MediaWiki\User\UserIdentityValue;
19use UnexpectedValueException;
20use Wikimedia\HtmlArmor\HtmlArmor;
21use Wikimedia\Rdbms\FakeResultWrapper;
22use Wikimedia\Rdbms\IResultWrapper;
23
24class AbuseFilterHistoryPager extends TablePager {
25
26    public function __construct(
27        IContextSource $context,
28        LinkRenderer $linkRenderer,
29        private readonly LinkBatchFactory $linkBatchFactory,
30        private readonly FilterLookup $filterLookup,
31        private readonly SpecsFormatter $specsFormatter,
32        private readonly AbuseFilterPermissionManager $afPermManager,
33        private readonly ?int $filter,
34        private readonly ?string $user,
35        private readonly bool $canViewPrivateFilters = false,
36        private readonly bool $canViewSuppressedFilters = false
37    ) {
38        parent::__construct( $context, $linkRenderer );
39        $this->mDefaultDirection = true;
40    }
41
42    /**
43     * Note: this method is called by parent::__construct
44     * @return array<string,string>
45     * @see MediaWiki\Pager\Pager::getFieldNames()
46     */
47    public function getFieldNames() {
48        static $headers = null;
49
50        if ( $headers !== null ) {
51            return $headers;
52        }
53
54        $headers = [
55            'afh_timestamp' => 'abusefilter-history-timestamp',
56            'afh_user_text' => 'abusefilter-history-user',
57            'afh_public_comments' => 'abusefilter-history-public',
58            'afh_flags' => 'abusefilter-history-flags',
59            'afh_actions' => 'abusefilter-history-actions',
60            'afh_id' => 'abusefilter-history-diff',
61        ];
62
63        if ( !$this->filter ) {
64            // awful hack
65            $headers = [ 'afh_filter' => 'abusefilter-history-filterid' ] + $headers;
66        }
67
68        foreach ( $headers as &$msg ) {
69            $msg = $this->msg( $msg )->text();
70        }
71
72        return $headers;
73    }
74
75    /**
76     * @param string $name
77     * @param string|null $value
78     * @return string
79     */
80    public function formatValue( $name, $value ) {
81        $lang = $this->getLanguage();
82        $linkRenderer = $this->getLinkRenderer();
83
84        $row = $this->mCurrentRow;
85
86        switch ( $name ) {
87            case 'afh_filter':
88                $formatted = $linkRenderer->makeLink(
89                    SpecialAbuseFilter::getTitleForSubpage( $row->afh_filter ),
90                    $lang->formatNum( $row->afh_filter )
91                );
92                break;
93            case 'afh_timestamp':
94                $title = SpecialAbuseFilter::getTitleForSubpage(
95                    'history/' . $row->afh_filter . '/item/' . $row->afh_id );
96                $formatted = $linkRenderer->makeLink(
97                    $title,
98                    $lang->userTimeAndDate( $row->afh_timestamp, $this->getUser() )
99                );
100                break;
101            case 'afh_user_text':
102                $formatted =
103                    Linker::userLink( $row->afh_user ?? 0, $row->afh_user_text ) . ' ' .
104                    Linker::userToolLinks( $row->afh_user ?? 0, $row->afh_user_text );
105                break;
106            case 'afh_public_comments':
107                $formatted = htmlspecialchars( $value, ENT_QUOTES, 'UTF-8', false );
108                break;
109            case 'afh_flags':
110                $formatted = $this->specsFormatter->formatFlags( $value, $lang );
111                break;
112            case 'afh_actions':
113                $actions = unserialize( $value );
114
115                $display_actions = '';
116
117                foreach ( $actions as $action => $parameters ) {
118                    $displayAction = $this->specsFormatter->formatAction( $action, $parameters, $lang );
119                    $display_actions .= Html::rawElement( 'li', [], $displayAction );
120                }
121                $display_actions = Html::rawElement( 'ul', [], $display_actions );
122
123                $formatted = $display_actions;
124                break;
125            case 'afh_id':
126                // Set a link to a diff with the previous version if this isn't the first edit to the filter.
127                // Like in AbuseFilterViewDiff, don't show it if:
128                // - the user cannot see private filters and any of the versions is hidden
129                // - the user cannot see protected variables and any of the versions is protected
130                $formatted = '';
131                if ( $this->filterLookup->getFirstFilterVersionID( $row->afh_filter ) !== (int)$value ) {
132                    // @todo Should we also hide actions?
133                    $prevFilter = $this->filterLookup->getClosestVersion(
134                        $row->afh_id, $row->afh_filter, FilterLookup::DIR_PREV );
135                    $filter = $this->filterLookup->filterFromHistoryRow( $row );
136                    $userCanSeeFilterDiff = true;
137
138                    // Protected variables permission check
139                    if ( $filter->isProtected() ) {
140                        $userCanSeeFilterDiff = $this->afPermManager
141                            ->canViewProtectedVariablesInFilter( $this->getAuthority(), $filter )
142                            ->isGood();
143                    }
144
145                    if ( $prevFilter->isProtected() && $userCanSeeFilterDiff ) {
146                        $userCanSeeFilterDiff = $this->afPermManager
147                            ->canViewProtectedVariablesInFilter( $this->getAuthority(), $prevFilter )
148                            ->isGood();
149                    }
150
151                    // Private filter visibility check
152                    if ( !$this->canViewPrivateFilters && $userCanSeeFilterDiff ) {
153                        $userCanSeeFilterDiff = !$filter->isHidden() && !$prevFilter->isHidden();
154                    }
155
156                    // Suppressed filter visibility check
157                    if ( $userCanSeeFilterDiff && !$this->canViewSuppressedFilters ) {
158                        // Use the filters' own suppressed state rather than parsing flags again
159                        if ( $filter->isSuppressed() || $prevFilter->isSuppressed() ) {
160                            $userCanSeeFilterDiff = false;
161                        }
162                    }
163
164                    if ( $userCanSeeFilterDiff ) {
165                        $title = SpecialAbuseFilter::getTitleForSubpage(
166                            'history/' . $row->afh_filter . "/diff/prev/$value" );
167                        $formatted = $linkRenderer->makeLink(
168                            $title,
169                            new HtmlArmor( $this->msg( 'abusefilter-history-diff' )->parse() )
170                        );
171                    }
172                }
173                break;
174            default:
175                throw new UnexpectedValueException( "Unknown row type $name!" );
176        }
177
178        return $formatted;
179    }
180
181    /**
182     * @return array
183     */
184    public function getQueryInfo() {
185        $queryBuilder = $this->filterLookup->getAbuseFilterHistoryQueryBuilder( $this->getDatabase() )
186            ->fields( [ 'af_hidden', 'afh_changed_fields' ] )
187            ->leftJoin( 'abuse_filter', null, 'afh_filter=af_id' );
188
189        if ( $this->user !== null ) {
190            $queryBuilder->andWhere( [ 'actor_name' => $this->user ] );
191        }
192
193        if ( $this->filter ) {
194            $queryBuilder->andWhere( [ 'afh_filter' => $this->filter ] );
195        }
196
197        // Hide data the user can't see.
198        if ( !$this->canViewSuppressedFilters ) {
199            $queryBuilder->andWhere( $this->mDb->bitAnd( 'af_hidden', Flags::FILTER_SUPPRESSED ) . ' = 0' );
200        }
201        if ( !$this->canViewPrivateFilters ) {
202            $queryBuilder->andWhere( $this->mDb->bitAnd( 'af_hidden', Flags::FILTER_HIDDEN ) . ' = 0' );
203        }
204
205        // We cannot know the variables used in the filters when we are running the SQL query, so
206        // assume no variables are used and the filter is just protected. We will filter out
207        // any filters which the user cannot see due to a specific variable later.
208        if ( !$this->afPermManager->canViewProtectedVariables( $this->getAuthority(), [] )->isGood() ) {
209            // Hide data the user can't see.
210            $queryBuilder->andWhere( $this->mDb->bitAnd( 'af_hidden', Flags::FILTER_USES_PROTECTED_VARS ) . ' = 0' );
211        }
212
213        return $queryBuilder->getQueryInfo();
214    }
215
216    /**
217     * Excludes rows which are for protected filters where the filter currently uses protected variables
218     * the user cannot see, to be consistent with how we exclude access to see the history of filters
219     * the user cannot currently see.
220     *
221     * This method repeats the query to get $limit rows that the user can see, so that we do not expose
222     * how many versions have been hidden.
223     *
224     * @inheritDoc
225     */
226    public function reallyDoQuery( $offset, $limit, $order ) {
227        $foundRows = [];
228        $currentOffset = $offset;
229
230        do {
231            $result = parent::reallyDoQuery( $currentOffset, $limit, $order );
232
233            // Loop over each row in the result, and check that the user can see the the current version of
234            // the filter which this is associated with.
235            foreach ( $result as $row ) {
236                $historyFilter = $this->filterLookup->filterFromHistoryRow( $row );
237                $currentFilterVersion = $this->filterLookup->getFilter(
238                    $historyFilter->getID(), $historyFilter->isGlobal()
239                );
240                if (
241                    !$currentFilterVersion->isProtected() ||
242                    $this->afPermManager
243                        ->canViewProtectedVariablesInFilter( $this->getAuthority(), $currentFilterVersion )
244                        ->isGood()
245                ) {
246                    $foundRows[] = $row;
247                }
248            }
249
250            // If we excluded rows in the above foreach, we will need to perform another query to get more rows so
251            // that the page contains a full list of results and does not expose the number of versions that
252            // the user cannot see.
253            // To do this we need to get a new offset value, which will be used to get rows we have not checked yet
254            // and is the timestamp of the last row we fetched.
255            $numRows = $result->numRows();
256
257            if ( $numRows ) {
258                $result->seek( $numRows - 1 );
259                $row = $result->fetchRow();
260                $currentOffset = $row['afh_timestamp'];
261            }
262        } while ( count( $foundRows ) <= $limit && $numRows );
263
264        $foundRows = array_slice( $foundRows, 0, $limit );
265        return new FakeResultWrapper( $foundRows );
266    }
267
268    /**
269     * @param IResultWrapper $result
270     */
271    protected function preprocessResults( $result ) {
272        if ( $this->getNumRows() === 0 ) {
273            return;
274        }
275
276        $lb = $this->linkBatchFactory->newLinkBatch();
277        $lb->setCaller( __METHOD__ );
278        foreach ( $result as $row ) {
279            $lb->addUser( new UserIdentityValue( $row->afh_user ?? 0, $row->afh_user_text ) );
280        }
281        $lb->execute();
282        $result->seek( 0 );
283    }
284
285    /**
286     * @codeCoverageIgnore Merely declarative
287     * @inheritDoc
288     */
289    public function getDefaultSort() {
290        return 'afh_timestamp';
291    }
292
293    /**
294     * @codeCoverageIgnore Merely declarative
295     * @inheritDoc
296     */
297    public function isFieldSortable( $field ) {
298        return $field === 'afh_timestamp';
299    }
300
301    /**
302     * @param string $field
303     * @param string $value
304     * @return array
305     * @see TablePager::getCellAttrs
306     */
307    public function getCellAttrs( $field, $value ) {
308        $row = $this->mCurrentRow;
309        $mappings = array_flip( AbuseFilter::HISTORY_MAPPINGS ) +
310            [ 'afh_actions' => 'actions', 'afh_id' => 'id' ];
311        $changed = explode( ',', $row->afh_changed_fields );
312
313        $fieldChanged = false;
314        if ( $field === 'afh_flags' ) {
315            // The field is changed if any of these filters are in the $changed array.
316            $filters = [ 'af_enabled', 'af_hidden', 'af_deleted', 'af_global' ];
317            if ( count( array_intersect( $filters, $changed ) ) ) {
318                $fieldChanged = true;
319            }
320        } elseif ( in_array( $mappings[$field], $changed ) ) {
321            $fieldChanged = true;
322        }
323
324        $class = $fieldChanged ? ' mw-abusefilter-history-changed' : '';
325        $attrs = parent::getCellAttrs( $field, $value );
326        $attrs['class'] .= $class;
327        return $attrs;
328    }
329
330    /** @inheritDoc */
331    protected function getRowClass( $row ) {
332        return 'mw-abusefilter-history-id-' . $row->afh_id;
333    }
334
335    /**
336     * Title used for self-links.
337     *
338     * @return Title
339     */
340    public function getTitle() {
341        $subpage = $this->filter ? ( 'history/' . $this->filter ) : 'history';
342        return SpecialAbuseFilter::getTitleForSubpage( $subpage );
343    }
344}