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