Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
38.14% covered (danger)
38.14%
82 / 215
11.11% covered (danger)
11.11%
1 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbuseFilterViewDiff
38.14% covered (danger)
38.14%
82 / 215
11.11% covered (danger)
11.11%
1 / 9
826.11
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
 show
41.03% covered (danger)
41.03%
16 / 39
0.00% covered (danger)
0.00%
0 / 1
17.05
 loadData
79.31% covered (warning)
79.31%
46 / 58
0.00% covered (danger)
0.00%
0 / 1
27.69
 loadSpec
78.26% covered (warning)
78.26%
18 / 23
0.00% covered (danger)
0.00%
0 / 1
13.48
 getVersionHeading
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
2
 formatDiff
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
42
 stringifyActions
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getHeaderRow
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getDiffRow
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\View;
4
5use MediaWiki\Content\TextContent;
6use MediaWiki\Context\IContextSource;
7use MediaWiki\Diff\DifferenceEngine;
8use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
9use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionStatus;
10use MediaWiki\Extension\AbuseFilter\Filter\ClosestFilterVersionNotFoundException;
11use MediaWiki\Extension\AbuseFilter\Filter\FilterNotFoundException;
12use MediaWiki\Extension\AbuseFilter\Filter\FilterVersionNotFoundException;
13use MediaWiki\Extension\AbuseFilter\Filter\HistoryFilter;
14use MediaWiki\Extension\AbuseFilter\FilterLookup;
15use MediaWiki\Extension\AbuseFilter\SpecsFormatter;
16use MediaWiki\Extension\AbuseFilter\TableDiffFormatterFullContext;
17use MediaWiki\Html\Html;
18use MediaWiki\Linker\Linker;
19use MediaWiki\Linker\LinkRenderer;
20use OOUI;
21use Wikimedia\Diff\Diff;
22
23class AbuseFilterViewDiff extends AbuseFilterView {
24    /**
25     * @var HistoryFilter|null The old version of the filter
26     */
27    public $oldVersion;
28    /**
29     * @var HistoryFilter|null The new version of the filter
30     */
31    public $newVersion;
32    /**
33     * @var int|null The history ID of the next version, if any
34     */
35    public $nextHistoryId;
36    /**
37     * @var int|null The ID of the filter
38     */
39    private $filter;
40
41    public function __construct(
42        AbuseFilterPermissionManager $afPermManager,
43        private readonly SpecsFormatter $specsFormatter,
44        private readonly FilterLookup $filterLookup,
45        IContextSource $context,
46        LinkRenderer $linkRenderer,
47        string $basePageName,
48        array $params
49    ) {
50        parent::__construct( $afPermManager, $context, $linkRenderer, $basePageName, $params );
51        $this->specsFormatter->setMessageLocalizer( $this->getContext() );
52    }
53
54    /**
55     * Shows the page
56     */
57    public function show() {
58        $show = $this->loadData();
59        $out = $this->getOutput();
60        $out->enableOOUI();
61        $out->addModuleStyles( [ 'oojs-ui.styles.icons-movement' ] );
62
63        $links = [];
64        if ( $this->filter ) {
65            $links['abusefilter-history-backedit'] = $this->getTitle( $this->filter )->getFullURL();
66            $links['abusefilter-diff-backhistory'] = $this->getTitle( "history/$this->filter" )->getFullURL();
67        }
68
69        foreach ( $links as $msg => $href ) {
70            $links[$msg] = new OOUI\ButtonWidget( [
71                'label' => $this->msg( $msg )->text(),
72                'href' => $href
73            ] );
74        }
75
76        $backlinks = (string)new OOUI\HorizontalLayout( [ 'items' => array_values( $links ) ] );
77        $out->addHTML( $backlinks );
78
79        if ( $show ) {
80            $out->addHTML( $this->formatDiff() );
81            // Next and previous change links
82            $buttons = [];
83            $oldHistoryID = $this->oldVersion->getHistoryID();
84            if ( $this->filterLookup->getFirstFilterVersionID( $this->filter ) !== $oldHistoryID ) {
85                // Create a "previous change" link if this isn't the first change of the given filter
86                $href = $this->getTitle( "history/$this->filter/diff/prev/$oldHistoryID" )->getFullURL();
87                $buttons[] = new OOUI\ButtonWidget( [
88                    'label' => $this->msg( 'abusefilter-diff-prev' )->text(),
89                    'href' => $href,
90                    'icon' => 'previous'
91                ] );
92            }
93
94            if ( $this->nextHistoryId !== null ) {
95                // Create a "next change" link if this isn't the last change of the given filter
96                $href = $this->getTitle( "history/$this->filter/diff/prev/$this->nextHistoryId" )->getFullURL();
97                $buttons[] = new OOUI\ButtonWidget( [
98                    'label' => $this->msg( 'abusefilter-diff-next' )->text(),
99                    'href' => $href,
100                    'icon' => 'next'
101                ] );
102            }
103
104            if ( count( $buttons ) > 0 ) {
105                $buttons = new OOUI\HorizontalLayout( [
106                    'items' => $buttons,
107                    'classes' => [ 'mw-abusefilter-history-buttons' ]
108                ] );
109                $out->addHTML( (string)$buttons );
110            }
111        }
112    }
113
114    /**
115     * @return bool
116     */
117    public function loadData() {
118        $oldSpec = $this->mParams[3];
119        $newSpec = $this->mParams[4];
120
121        if ( !is_numeric( $this->mParams[1] ) ) {
122            $this->getOutput()->addWikiMsg( 'abusefilter-diff-invalid' );
123            return false;
124        }
125        $this->filter = (int)$this->mParams[1];
126
127        $this->oldVersion = $this->loadSpec( $oldSpec, $newSpec );
128        $this->newVersion = $this->loadSpec( $newSpec, $oldSpec );
129
130        if ( $this->oldVersion === null || $this->newVersion === null ) {
131            $this->getOutput()->addWikiMsg( 'abusefilter-diff-invalid' );
132            return false;
133        }
134
135        if ( !$this->afPermManager->canViewSuppressed( $this->getAuthority() ) &&
136            ( $this->oldVersion->isSuppressed() || $this->newVersion->isSuppressed() )
137        ) {
138            $this->getOutput()->addWikiMsg( 'abusefilter-history-error-suppressed' );
139            return false;
140        }
141
142        if ( !$this->afPermManager->canViewPrivateFilters( $this->getAuthority() ) &&
143            ( $this->oldVersion->isHidden() || $this->newVersion->isHidden() )
144        ) {
145            $this->getOutput()->addWikiMsg( 'abusefilter-history-error-hidden' );
146            return false;
147        }
148
149        if ( $this->oldVersion->isProtected() || $this->newVersion->isProtected() ) {
150            $oldPermissionStatus = AbuseFilterPermissionStatus::newGood();
151            if ( $this->oldVersion->isProtected() ) {
152                $oldPermissionStatus = $this->afPermManager->canViewProtectedVariablesInFilter(
153                    $this->getAuthority(), $this->oldVersion
154                );
155            }
156
157            $newPermissionStatus = AbuseFilterPermissionStatus::newGood();
158            if ( $this->newVersion->isProtected() ) {
159                $newPermissionStatus = $this->afPermManager->canViewProtectedVariablesInFilter(
160                    $this->getAuthority(), $this->newVersion
161                );
162            }
163
164            if ( !$newPermissionStatus->isGood() || !$oldPermissionStatus->isGood() ) {
165                if ( $oldPermissionStatus->getPermission() || $newPermissionStatus->getPermission() ) {
166                    $permission = $oldPermissionStatus->getPermission();
167                    if ( !$permission ) {
168                        $permission = $newPermissionStatus->getPermission();
169                    }
170
171                    $this->getOutput()->addWikiMsg( $this->msg(
172                        'abusefilter-history-error-protected-due-to-permission',
173                        $this->msg( "action-$permission" )->plain()
174                    ) );
175                    return false;
176                }
177
178                // Add any messages in the status after a generic error message.
179                $additional = '';
180                foreach ( $oldPermissionStatus->getMessages() as $message ) {
181                    $additional .= $this->msg( $message )->parseAsBlock();
182                }
183                if ( $additional === '' ) {
184                    // Only add the error messages for the new version if no messages were present for the old
185                    // version. This is done to avoid duplicate error messages that were relevant to both
186                    // the old and new version.
187                    foreach ( $newPermissionStatus->getMessages() as $message ) {
188                        $additional .= $this->msg( $message )->parseAsBlock();
189                    }
190                }
191
192                $this->getOutput()->addWikiMsg(
193                    $this->msg( 'abusefilter-history-error-protected' )->rawParams( $additional )
194                );
195                return false;
196            }
197        }
198
199        try {
200            $this->nextHistoryId = $this->filterLookup->getClosestVersion(
201                $this->newVersion->getHistoryID(),
202                $this->filter,
203                FilterLookup::DIR_NEXT
204            )->getHistoryID();
205        } catch ( ClosestFilterVersionNotFoundException ) {
206            $this->nextHistoryId = null;
207        }
208
209        return true;
210    }
211
212    /**
213     * @param string $spec
214     * @param string $otherSpec
215     * @return HistoryFilter|null
216     */
217    public function loadSpec( $spec, $otherSpec ): ?HistoryFilter {
218        static $dependentSpecs = [ 'prev', 'next' ];
219        static $cache = [];
220
221        if ( isset( $cache[$spec] ) ) {
222            return $cache[$spec];
223        }
224
225        $filterObj = null;
226        if ( ( $spec === 'prev' || $spec === 'next' ) && !in_array( $otherSpec, $dependentSpecs ) ) {
227            $other = $this->loadSpec( $otherSpec, $spec );
228
229            if ( !$other ) {
230                return null;
231            }
232
233            $dir = $spec === 'prev' ? FilterLookup::DIR_PREV : FilterLookup::DIR_NEXT;
234            try {
235                $filterObj = $this->filterLookup->getClosestVersion( $other->getHistoryID(), $this->filter, $dir );
236            } catch ( ClosestFilterVersionNotFoundException ) {
237                $t = $this->getTitle( "history/$this->filter/item/" . $other->getHistoryID() );
238                $this->getOutput()->redirect( $t->getFullURL() );
239                return null;
240            }
241        }
242
243        if ( $filterObj === null ) {
244            try {
245                if ( is_numeric( $spec ) ) {
246                    $filterObj = $this->filterLookup->getFilterVersion( (int)$spec );
247                } elseif ( $spec === 'cur' ) {
248                    $filterObj = $this->filterLookup->getLastHistoryVersion( $this->filter );
249                }
250            } catch ( FilterNotFoundException | FilterVersionNotFoundException ) {
251            }
252        }
253
254        $cache[$spec] = $filterObj;
255        return $cache[$spec];
256    }
257
258    /**
259     * @param HistoryFilter $filterVersion
260     * @return string raw html for the <th> element
261     */
262    private function getVersionHeading( HistoryFilter $filterVersion ) {
263        $text = $this->getLanguage()->userTimeAndDate(
264            $filterVersion->getTimestamp(),
265            $this->getUser()
266        );
267        $history_id = $filterVersion->getHistoryID();
268        $title = $this->getTitle( "history/$this->filter/item/$history_id" );
269
270        $versionLink = $this->linkRenderer->makeLink( $title, $text );
271        $userLink = Linker::userLink(
272            $filterVersion->getUserID(),
273            $filterVersion->getUserName()
274        );
275        return Html::rawElement(
276            'th',
277            [],
278            $this->msg( 'abusefilter-diff-version' )
279                ->rawParams( $versionLink, $userLink )
280                ->params( $filterVersion->getUserName() )
281                ->parse()
282        );
283    }
284
285    /**
286     * @return string
287     */
288    public function formatDiff() {
289        $oldVersion = $this->oldVersion;
290        $newVersion = $this->newVersion;
291
292        // headings
293        $headings = Html::rawElement(
294            'th',
295            [],
296            $this->msg( 'abusefilter-diff-item' )->parse()
297        );
298        $headings .= $this->getVersionHeading( $oldVersion );
299        $headings .= $this->getVersionHeading( $newVersion );
300        $headings = Html::rawElement( 'tr', [], $headings );
301
302        $body = '';
303        // Basic info
304        $info = $this->getDiffRow( 'abusefilter-edit-description', $oldVersion->getName(), $newVersion->getName() );
305        $info .= $this->getDiffRow(
306            'abusefilter-edit-group',
307            $this->specsFormatter->nameGroup( $oldVersion->getGroup() ),
308            $this->specsFormatter->nameGroup( $newVersion->getGroup() )
309        );
310        $info .= $this->getDiffRow(
311            'abusefilter-edit-flags',
312            $this->specsFormatter->formatFilterFlags( $oldVersion, $this->getLanguage() ),
313            $this->specsFormatter->formatFilterFlags( $newVersion, $this->getLanguage() )
314        );
315
316        $info .= $this->getDiffRow( 'abusefilter-edit-notes', $oldVersion->getComments(), $newVersion->getComments() );
317        if ( $info !== '' ) {
318            $body .= $this->getHeaderRow( 'abusefilter-diff-info' ) . $info;
319        }
320
321        $pattern = $this->getDiffRow( 'abusefilter-edit-rules', $oldVersion->getRules(), $newVersion->getRules() );
322        if ( $pattern !== '' ) {
323            $body .= $this->getHeaderRow( 'abusefilter-diff-pattern' ) . $pattern;
324        }
325
326        $actions = $this->getDiffRow(
327            'abusefilter-edit-consequences',
328            $this->stringifyActions( $oldVersion->getActions() ) ?: [ '' ],
329            $this->stringifyActions( $newVersion->getActions() ) ?: [ '' ]
330        );
331        if ( $actions !== '' ) {
332            $body .= $this->getHeaderRow( 'abusefilter-edit-consequences' ) . $actions;
333        }
334
335        $tableHead = Html::rawElement( 'thead', [], $headings );
336        $tableBody = Html::rawElement( 'tbody', [], $body );
337        $table = Html::rawElement(
338            'table',
339            [ 'class' => 'wikitable' ],
340            $tableHead . $tableBody
341        );
342
343        return Html::rawElement( 'h2', [], $this->msg( 'abusefilter-diff-title' )->parse() ) .
344            $table;
345    }
346
347    /**
348     * @param string[][] $actions
349     * @return string[]
350     */
351    private function stringifyActions( array $actions ): array {
352        $lines = [];
353
354        ksort( $actions );
355        $language = $this->getLanguage();
356        foreach ( $actions as $action => $parameters ) {
357            $lines[] = $this->specsFormatter->formatAction( $action, $parameters, $language );
358        }
359
360        return $lines;
361    }
362
363    /**
364     * @param string $key
365     * @return string
366     */
367    public function getHeaderRow( $key ) {
368        $msg = $this->msg( $key )->parse();
369        return Html::rawElement(
370            'tr',
371            [ 'class' => 'mw-abusefilter-diff-header' ],
372            Html::rawElement( 'th', [ 'colspan' => 3 ], $msg )
373        );
374    }
375
376    /**
377     * @param string $key
378     * @param array|string $old
379     * @param array|string $new
380     * @return string
381     */
382    public function getDiffRow( $key, $old, $new ) {
383        if ( !is_array( $old ) ) {
384            $old = explode( "\n", TextContent::normalizeLineEndings( $old ) );
385        }
386        if ( !is_array( $new ) ) {
387            $new = explode( "\n", TextContent::normalizeLineEndings( $new ) );
388        }
389
390        if ( $old === $new ) {
391            return '';
392        }
393
394        $diffEngine = new DifferenceEngine( $this->getContext() );
395
396        $diffEngine->showDiffStyle();
397
398        $diff = new Diff( $old, $new );
399        $formatter = new TableDiffFormatterFullContext();
400        $formattedDiff = $diffEngine->addHeader( $formatter->format( $diff ), '', '' );
401
402        $heading = Html::rawElement( 'th', [], $this->msg( $key )->parse() );
403        $bodyCell = Html::rawElement( 'td', [ 'colspan' => 2 ], $formattedDiff );
404        return Html::rawElement(
405            'tr',
406            [],
407            $heading . $bodyCell
408        ) . "\n";
409    }
410}