Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
95.95% |
142 / 148 |
|
77.78% |
7 / 9 |
CRAP | |
0.00% |
0 / 1 |
AbuseFilterHistoryPager | |
95.95% |
142 / 148 |
|
77.78% |
7 / 9 |
47 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
getFieldNames | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
4 | |||
formatValue | |
92.19% |
59 / 64 |
|
0.00% |
0 / 1 |
18.15 | |||
getQueryInfo | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
5.01 | |||
reallyDoQuery | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
6 | |||
preprocessResults | |
100.00% |
8 / 8 |
|
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% |
15 / 15 |
|
100.00% |
1 / 1 |
5 | |||
getRowClass | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getTitle | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\AbuseFilter\Pager; |
4 | |
5 | use HtmlArmor; |
6 | use MediaWiki\Cache\LinkBatchFactory; |
7 | use MediaWiki\Context\IContextSource; |
8 | use MediaWiki\Extension\AbuseFilter\AbuseFilter; |
9 | use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager; |
10 | use MediaWiki\Extension\AbuseFilter\Filter\Flags; |
11 | use MediaWiki\Extension\AbuseFilter\FilterLookup; |
12 | use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseFilter; |
13 | use MediaWiki\Extension\AbuseFilter\SpecsFormatter; |
14 | use MediaWiki\Html\Html; |
15 | use MediaWiki\Linker\Linker; |
16 | use MediaWiki\Linker\LinkRenderer; |
17 | use MediaWiki\Pager\TablePager; |
18 | use MediaWiki\Title\Title; |
19 | use MediaWiki\User\UserIdentityValue; |
20 | use UnexpectedValueException; |
21 | use Wikimedia\Rdbms\FakeResultWrapper; |
22 | use Wikimedia\Rdbms\IResultWrapper; |
23 | |
24 | class 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 | } |