Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
37.91% covered (danger)
37.91%
80 / 211
11.11% covered (danger)
11.11%
1 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbuseFilterViewDiff
37.91% covered (danger)
37.91%
80 / 211
11.11% covered (danger)
11.11%
1 / 9
751.84
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
81.48% covered (warning)
81.48%
44 / 54
0.00% covered (danger)
0.00%
0 / 1
22.54
 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 DifferenceEngine;
6use MediaWiki\Content\TextContent;
7use MediaWiki\Context\IContextSource;
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 = 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( $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->canViewPrivateFilters( $this->getAuthority() ) &&
136            ( $this->oldVersion->isHidden() || $this->newVersion->isHidden() )
137        ) {
138            $this->getOutput()->addWikiMsg( 'abusefilter-history-error-hidden' );
139            return false;
140        }
141
142        if ( $this->oldVersion->isProtected() || $this->newVersion->isProtected() ) {
143            $oldPermissionStatus = AbuseFilterPermissionStatus::newGood();
144            if ( $this->oldVersion->isProtected() ) {
145                $oldPermissionStatus = $this->afPermManager->canViewProtectedVariablesInFilter(
146                    $this->getAuthority(), $this->oldVersion
147                );
148            }
149
150            $newPermissionStatus = AbuseFilterPermissionStatus::newGood();
151            if ( $this->newVersion->isProtected() ) {
152                $newPermissionStatus = $this->afPermManager->canViewProtectedVariablesInFilter(
153                    $this->getAuthority(), $this->newVersion
154                );
155            }
156
157            if ( !$newPermissionStatus->isGood() || !$oldPermissionStatus->isGood() ) {
158                if ( $oldPermissionStatus->getPermission() || $newPermissionStatus->getPermission() ) {
159                    $permission = $oldPermissionStatus->getPermission();
160                    if ( !$permission ) {
161                        $permission = $newPermissionStatus->getPermission();
162                    }
163
164                    $this->getOutput()->addWikiMsg( $this->msg(
165                        'abusefilter-history-error-protected-due-to-permission',
166                        $this->msg( "action-$permission" )->plain()
167                    ) );
168                    return false;
169                }
170
171                // Add any messages in the status after a generic error message.
172                $additional = '';
173                foreach ( $oldPermissionStatus->getMessages() as $message ) {
174                    $additional .= $this->msg( $message )->parseAsBlock();
175                }
176                if ( $additional === '' ) {
177                    // Only add the error messages for the new version if no messages were present for the old
178                    // version. This is done to avoid duplicate error messages that were relevant to both
179                    // the old and new version.
180                    foreach ( $newPermissionStatus->getMessages() as $message ) {
181                        $additional .= $this->msg( $message )->parseAsBlock();
182                    }
183                }
184
185                $this->getOutput()->addWikiMsg(
186                    $this->msg( 'abusefilter-history-error-protected' )->rawParams( $additional )
187                );
188                return false;
189            }
190        }
191
192        try {
193            $this->nextHistoryId = $this->filterLookup->getClosestVersion(
194                $this->newVersion->getHistoryID(),
195                $this->filter,
196                FilterLookup::DIR_NEXT
197            )->getHistoryID();
198        } catch ( ClosestFilterVersionNotFoundException $_ ) {
199            $this->nextHistoryId = null;
200        }
201
202        return true;
203    }
204
205    /**
206     * @param string $spec
207     * @param string $otherSpec
208     * @return HistoryFilter|null
209     */
210    public function loadSpec( $spec, $otherSpec ): ?HistoryFilter {
211        static $dependentSpecs = [ 'prev', 'next' ];
212        static $cache = [];
213
214        if ( isset( $cache[$spec] ) ) {
215            return $cache[$spec];
216        }
217
218        $filterObj = null;
219        if ( ( $spec === 'prev' || $spec === 'next' ) && !in_array( $otherSpec, $dependentSpecs ) ) {
220            $other = $this->loadSpec( $otherSpec, $spec );
221
222            if ( !$other ) {
223                return null;
224            }
225
226            $dir = $spec === 'prev' ? FilterLookup::DIR_PREV : FilterLookup::DIR_NEXT;
227            try {
228                $filterObj = $this->filterLookup->getClosestVersion( $other->getHistoryID(), $this->filter, $dir );
229            } catch ( ClosestFilterVersionNotFoundException $_ ) {
230                $t = $this->getTitle( "history/$this->filter/item/" . $other->getHistoryID() );
231                $this->getOutput()->redirect( $t->getFullURL() );
232                return null;
233            }
234        }
235
236        if ( $filterObj === null ) {
237            try {
238                if ( is_numeric( $spec ) ) {
239                    $filterObj = $this->filterLookup->getFilterVersion( (int)$spec );
240                } elseif ( $spec === 'cur' ) {
241                    $filterObj = $this->filterLookup->getLastHistoryVersion( $this->filter );
242                }
243            } catch ( FilterNotFoundException | FilterVersionNotFoundException $_ ) {
244            }
245        }
246
247        $cache[$spec] = $filterObj;
248        return $cache[$spec];
249    }
250
251    /**
252     * @param HistoryFilter $filterVersion
253     * @return string raw html for the <th> element
254     */
255    private function getVersionHeading( HistoryFilter $filterVersion ) {
256        $text = $this->getLanguage()->userTimeAndDate(
257            $filterVersion->getTimestamp(),
258            $this->getUser()
259        );
260        $history_id = $filterVersion->getHistoryID();
261        $title = $this->getTitle( "history/$this->filter/item/$history_id" );
262
263        $versionLink = $this->linkRenderer->makeLink( $title, $text );
264        $userLink = Linker::userLink(
265            $filterVersion->getUserID(),
266            $filterVersion->getUserName()
267        );
268        return Html::rawElement(
269            'th',
270            [],
271            $this->msg( 'abusefilter-diff-version' )
272                ->rawParams( $versionLink, $userLink )
273                ->params( $filterVersion->getUserName() )
274                ->parse()
275        );
276    }
277
278    /**
279     * @return string
280     */
281    public function formatDiff() {
282        $oldVersion = $this->oldVersion;
283        $newVersion = $this->newVersion;
284
285        // headings
286        $headings = Html::rawElement(
287            'th',
288            [],
289            $this->msg( 'abusefilter-diff-item' )->parse()
290        );
291        $headings .= $this->getVersionHeading( $oldVersion );
292        $headings .= $this->getVersionHeading( $newVersion );
293        $headings = Html::rawElement( 'tr', [], $headings );
294
295        $body = '';
296        // Basic info
297        $info = $this->getDiffRow( 'abusefilter-edit-description', $oldVersion->getName(), $newVersion->getName() );
298        $info .= $this->getDiffRow(
299            'abusefilter-edit-group',
300            $this->specsFormatter->nameGroup( $oldVersion->getGroup() ),
301            $this->specsFormatter->nameGroup( $newVersion->getGroup() )
302        );
303        $info .= $this->getDiffRow(
304            'abusefilter-edit-flags',
305            $this->specsFormatter->formatFilterFlags( $oldVersion, $this->getLanguage() ),
306            $this->specsFormatter->formatFilterFlags( $newVersion, $this->getLanguage() )
307        );
308
309        $info .= $this->getDiffRow( 'abusefilter-edit-notes', $oldVersion->getComments(), $newVersion->getComments() );
310        if ( $info !== '' ) {
311            $body .= $this->getHeaderRow( 'abusefilter-diff-info' ) . $info;
312        }
313
314        $pattern = $this->getDiffRow( 'abusefilter-edit-rules', $oldVersion->getRules(), $newVersion->getRules() );
315        if ( $pattern !== '' ) {
316            $body .= $this->getHeaderRow( 'abusefilter-diff-pattern' ) . $pattern;
317        }
318
319        $actions = $this->getDiffRow(
320            'abusefilter-edit-consequences',
321            $this->stringifyActions( $oldVersion->getActions() ) ?: [ '' ],
322            $this->stringifyActions( $newVersion->getActions() ) ?: [ '' ]
323        );
324        if ( $actions !== '' ) {
325            $body .= $this->getHeaderRow( 'abusefilter-edit-consequences' ) . $actions;
326        }
327
328        $tableHead = Html::rawElement( 'thead', [], $headings );
329        $tableBody = Html::rawElement( 'tbody', [], $body );
330        $table = Html::rawElement(
331            'table',
332            [ 'class' => 'wikitable' ],
333            $tableHead . $tableBody
334        );
335
336        return Html::rawElement( 'h2', [], $this->msg( 'abusefilter-diff-title' )->parse() ) .
337            $table;
338    }
339
340    /**
341     * @param string[][] $actions
342     * @return string[]
343     */
344    private function stringifyActions( array $actions ): array {
345        $lines = [];
346
347        ksort( $actions );
348        $language = $this->getLanguage();
349        foreach ( $actions as $action => $parameters ) {
350            $lines[] = $this->specsFormatter->formatAction( $action, $parameters, $language );
351        }
352
353        return $lines;
354    }
355
356    /**
357     * @param string $key
358     * @return string
359     */
360    public function getHeaderRow( $key ) {
361        $msg = $this->msg( $key )->parse();
362        return Html::rawElement(
363            'tr',
364            [ 'class' => 'mw-abusefilter-diff-header' ],
365            Html::rawElement( 'th', [ 'colspan' => 3 ], $msg )
366        );
367    }
368
369    /**
370     * @param string $key
371     * @param array|string $old
372     * @param array|string $new
373     * @return string
374     */
375    public function getDiffRow( $key, $old, $new ) {
376        if ( !is_array( $old ) ) {
377            $old = explode( "\n", TextContent::normalizeLineEndings( $old ) );
378        }
379        if ( !is_array( $new ) ) {
380            $new = explode( "\n", TextContent::normalizeLineEndings( $new ) );
381        }
382
383        if ( $old === $new ) {
384            return '';
385        }
386
387        $diffEngine = new DifferenceEngine( $this->getContext() );
388
389        $diffEngine->showDiffStyle();
390
391        $diff = new Diff( $old, $new );
392        $formatter = new TableDiffFormatterFullContext();
393        $formattedDiff = $diffEngine->addHeader( $formatter->format( $diff ), '', '' );
394
395        $heading = Html::rawElement( 'th', [], $this->msg( $key )->parse() );
396        $bodyCell = Html::rawElement( 'td', [ 'colspan' => 2 ], $formattedDiff );
397        return Html::rawElement(
398            'tr',
399            [],
400            $heading . $bodyCell
401        ) . "\n";
402    }
403}