Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
3.85% covered (danger)
3.85%
8 / 208
12.50% covered (danger)
12.50%
1 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbuseFilterViewRevert
3.85% covered (danger)
3.85%
8 / 208
12.50% covered (danger)
12.50%
1 / 8
776.65
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 show
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
30
 showRevertableActions
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
20
 doLookup
0.00% covered (danger)
0.00%
0 / 54
0.00% covered (danger)
0.00%
0 / 1
42
 loadParameters
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 attemptRevert
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 getConsequence
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 revertAction
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\View;
4
5use HTMLForm;
6use IContextSource;
7use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
8use MediaWiki\Extension\AbuseFilter\ActionSpecifier;
9use MediaWiki\Extension\AbuseFilter\Consequences\Consequence\ReversibleConsequence;
10use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesFactory;
11use MediaWiki\Extension\AbuseFilter\Consequences\Parameters;
12use MediaWiki\Extension\AbuseFilter\FilterLookup;
13use MediaWiki\Extension\AbuseFilter\SpecsFormatter;
14use MediaWiki\Extension\AbuseFilter\Variables\UnsetVariableException;
15use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
16use MediaWiki\Html\Html;
17use MediaWiki\Linker\Linker;
18use MediaWiki\Linker\LinkRenderer;
19use MediaWiki\SpecialPage\SpecialPage;
20use MediaWiki\Title\TitleValue;
21use MediaWiki\User\UserFactory;
22use Message;
23use PermissionsError;
24use UnexpectedValueException;
25use UserBlockedError;
26use Wikimedia\Rdbms\LBFactory;
27use Xml;
28
29class AbuseFilterViewRevert extends AbuseFilterView {
30    /** @var int */
31    private $filter;
32    /**
33     * @var string|null The start time of the lookup period
34     */
35    private $periodStart;
36    /**
37     * @var string|null The end time of the lookup period
38     */
39    private $periodEnd;
40    /**
41     * @var string|null The reason provided for the revert
42     */
43    private $reason;
44    /**
45     * @var LBFactory
46     */
47    private $lbFactory;
48    /**
49     * @var UserFactory
50     */
51    private $userFactory;
52    /**
53     * @var FilterLookup
54     */
55    private $filterLookup;
56    /**
57     * @var ConsequencesFactory
58     */
59    private $consequencesFactory;
60    /**
61     * @var VariablesBlobStore
62     */
63    private $varBlobStore;
64    /**
65     * @var SpecsFormatter
66     */
67    private $specsFormatter;
68
69    /**
70     * @param LBFactory $lbFactory
71     * @param UserFactory $userFactory
72     * @param AbuseFilterPermissionManager $afPermManager
73     * @param FilterLookup $filterLookup
74     * @param ConsequencesFactory $consequencesFactory
75     * @param VariablesBlobStore $varBlobStore
76     * @param SpecsFormatter $specsFormatter
77     * @param IContextSource $context
78     * @param LinkRenderer $linkRenderer
79     * @param string $basePageName
80     * @param array $params
81     */
82    public function __construct(
83        LBFactory $lbFactory,
84        UserFactory $userFactory,
85        AbuseFilterPermissionManager $afPermManager,
86        FilterLookup $filterLookup,
87        ConsequencesFactory $consequencesFactory,
88        VariablesBlobStore $varBlobStore,
89        SpecsFormatter $specsFormatter,
90        IContextSource $context,
91        LinkRenderer $linkRenderer,
92        string $basePageName,
93        array $params
94    ) {
95        parent::__construct( $afPermManager, $context, $linkRenderer, $basePageName, $params );
96        $this->lbFactory = $lbFactory;
97        $this->userFactory = $userFactory;
98        $this->filterLookup = $filterLookup;
99        $this->consequencesFactory = $consequencesFactory;
100        $this->varBlobStore = $varBlobStore;
101        $this->specsFormatter = $specsFormatter;
102        $this->specsFormatter->setMessageLocalizer( $this->getContext() );
103    }
104
105    /**
106     * Shows the page
107     */
108    public function show() {
109        $lang = $this->getLanguage();
110
111        $performer = $this->getAuthority();
112        $out = $this->getOutput();
113
114        if ( !$this->afPermManager->canRevertFilterActions( $performer ) ) {
115            throw new PermissionsError( 'abusefilter-revert' );
116        }
117
118        $block = $performer->getBlock();
119        if ( $block && $block->isSitewide() ) {
120            throw new UserBlockedError( $block );
121        }
122
123        $this->loadParameters();
124
125        if ( $this->attemptRevert() ) {
126            return;
127        }
128
129        $filter = $this->filter;
130
131        $out->addWikiMsg( 'abusefilter-revert-intro', Message::numParam( $filter ) );
132        // Parse wikitext in this message to allow formatting of numero signs (T343994#9209383)
133        $out->setPageTitle( $this->msg( 'abusefilter-revert-title' )->numParams( $filter )->parse() );
134
135        // First, the search form. Limit dates to avoid huge queries
136        $RCMaxAge = $this->getConfig()->get( 'RCMaxAge' );
137        $min = wfTimestamp( TS_ISO_8601, time() - $RCMaxAge );
138        $max = wfTimestampNow();
139        $filterLink =
140            $this->linkRenderer->makeLink(
141                $this->getTitle( $filter ),
142                $lang->formatNum( $filter )
143            );
144        $searchFields = [];
145        $searchFields['filterid'] = [
146            'type' => 'info',
147            'default' => $filterLink,
148            'raw' => true,
149            'label-message' => 'abusefilter-revert-filter'
150        ];
151        $searchFields['PeriodStart'] = [
152            'type' => 'datetime',
153            'label-message' => 'abusefilter-revert-periodstart',
154            'min' => $min,
155            'max' => $max
156        ];
157        $searchFields['PeriodEnd'] = [
158            'type' => 'datetime',
159            'label-message' => 'abusefilter-revert-periodend',
160            'min' => $min,
161            'max' => $max
162        ];
163
164        HTMLForm::factory( 'ooui', $searchFields, $this->getContext() )
165            ->setTitle( $this->getTitle( "revert/$filter" ) )
166            ->setWrapperLegendMsg( 'abusefilter-revert-search-legend' )
167            ->setSubmitTextMsg( 'abusefilter-revert-search' )
168            ->setMethod( 'get' )
169            ->setFormIdentifier( 'revert-select-date' )
170            ->setSubmitCallback( [ $this, 'showRevertableActions' ] )
171            ->showAlways();
172    }
173
174    /**
175     * Show revertable actions, called as submit callback by HTMLForm
176     * @param array $formData
177     * @param HTMLForm $dateForm
178     * @return bool
179     */
180    public function showRevertableActions( array $formData, HTMLForm $dateForm ): bool {
181        $lang = $this->getLanguage();
182        $user = $this->getUser();
183        $filter = $this->filter;
184
185        // Look up all of them.
186        $results = $this->doLookup();
187        if ( $results === [] ) {
188            $dateForm->addPostHtml( $this->msg( 'abusefilter-revert-preview-no-results' )->escaped() );
189            return true;
190        }
191
192        // Add a summary of everything that will be reversed.
193        $dateForm->addPostHtml( $this->msg( 'abusefilter-revert-preview-intro' )->parseAsBlock() );
194        $list = [];
195
196        foreach ( $results as $result ) {
197            $displayActions = [];
198            foreach ( $result['actions'] as $action ) {
199                $displayActions[] = $this->specsFormatter->getActionDisplay( $action );
200            }
201
202            /** @var ActionSpecifier $spec */
203            $spec = $result['spec'];
204            $msg = $this->msg( 'abusefilter-revert-preview-item' )
205                ->params(
206                    $lang->userTimeAndDate( $result['timestamp'], $user )
207                )->rawParams(
208                    Linker::userLink( $spec->getUser()->getId(), $spec->getUser()->getName() )
209                )->params(
210                    $spec->getAction()
211                )->rawParams(
212                    $this->linkRenderer->makeLink( $spec->getTitle() )
213                )->params(
214                    $lang->commaList( $displayActions )
215                )->rawParams(
216                    $this->linkRenderer->makeLink(
217                        SpecialPage::getTitleFor( 'AbuseLog' ),
218                        $this->msg( 'abusefilter-log-detailslink' )->text(),
219                        [],
220                        [ 'details' => $result['id'] ]
221                    )
222                )->params(
223                    $spec->getUser()->getName()
224                )->parse();
225            $list[] = Xml::tags( 'li', null, $msg );
226        }
227
228        $dateForm->addPostHtml( Xml::tags( 'ul', null, implode( "\n", $list ) ) );
229
230        // Add a button down the bottom.
231        $confirmForm = [];
232        $confirmForm['PeriodStart'] = [
233            'type' => 'hidden',
234        ];
235        $confirmForm['PeriodEnd'] = [
236            'type' => 'hidden',
237        ];
238        $confirmForm['Reason'] = [
239            'type' => 'text',
240            'label-message' => 'abusefilter-revert-reasonfield',
241            'id' => 'wpReason',
242        ];
243
244        $revertForm = HTMLForm::factory( 'ooui', $confirmForm, $this->getContext() )
245            ->setTitle( $this->getTitle( "revert/$filter" ) )
246            ->setTokenSalt( "abusefilter-revert-$filter" )
247            ->setWrapperLegendMsg( 'abusefilter-revert-confirm-legend' )
248            ->setSubmitTextMsg( 'abusefilter-revert-confirm' )
249            ->prepareForm()
250            ->getHTML( true );
251        $dateForm->addPostHtml( $revertForm );
252
253        return true;
254    }
255
256    /**
257     * @return array[]
258     */
259    public function doLookup() {
260        $periodStart = $this->periodStart;
261        $periodEnd = $this->periodEnd;
262        $filter = $this->filter;
263        $dbr = $this->lbFactory->getReplicaDatabase();
264
265        // Only hits from local filters can be reverted
266        $conds = [ 'afl_filter_id' => $filter, 'afl_global' => 0 ];
267
268        if ( $periodStart !== null ) {
269            $conds[] = 'afl_timestamp >= ' . $dbr->addQuotes( $dbr->timestamp( $periodStart ) );
270        }
271        if ( $periodEnd !== null ) {
272            $conds[] = 'afl_timestamp <= ' . $dbr->addQuotes( $dbr->timestamp( $periodEnd ) );
273        }
274
275        // Don't revert if there was no action, or the action was global
276        $conds[] = 'afl_actions != ' . $dbr->addQuotes( '' );
277        $conds[] = 'afl_wiki IS NULL';
278
279        $selectFields = [
280            'afl_id',
281            'afl_user',
282            'afl_user_text',
283            'afl_ip',
284            'afl_action',
285            'afl_actions',
286            'afl_var_dump',
287            'afl_timestamp',
288            'afl_namespace',
289            'afl_title',
290        ];
291        $res = $dbr->select(
292            'abuse_filter_log',
293            $selectFields,
294            $conds,
295            __METHOD__,
296            [ 'ORDER BY' => 'afl_timestamp DESC' ]
297        );
298
299        // TODO: get the following from ConsequencesRegistry or sth else
300        static $reversibleActions = [ 'block', 'blockautopromote', 'degroup' ];
301
302        $results = [];
303        foreach ( $res as $row ) {
304            $actions = explode( ',', $row->afl_actions );
305            $currentReversibleActions = array_intersect( $actions, $reversibleActions );
306            if ( count( $currentReversibleActions ) ) {
307                $vars = $this->varBlobStore->loadVarDump( $row->afl_var_dump );
308                try {
309                    // The variable is not lazy-loaded
310                    $accountName = $vars->getComputedVariable( 'accountname' )->toNative();
311                } catch ( UnsetVariableException $_ ) {
312                    $accountName = null;
313                }
314                $results[] = [
315                    'id' => $row->afl_id,
316                    'actions' => $currentReversibleActions,
317                    'vars' => $vars,
318                    'spec' => new ActionSpecifier(
319                        $row->afl_action,
320                        new TitleValue( (int)$row->afl_namespace, $row->afl_title ),
321                        $this->userFactory->newFromAnyId( (int)$row->afl_user, $row->afl_user_text ),
322                        $row->afl_ip,
323                        $accountName
324                    ),
325                    'timestamp' => $row->afl_timestamp
326                ];
327            }
328        }
329
330        return $results;
331    }
332
333    /**
334     * Loads parameters from request
335     */
336    public function loadParameters() {
337        $request = $this->getRequest();
338
339        $this->filter = (int)$this->mParams[1];
340        $this->periodStart = strtotime( $request->getText( 'wpPeriodStart' ) ) ?: null;
341        $this->periodEnd = strtotime( $request->getText( 'wpPeriodEnd' ) ) ?: null;
342        $this->reason = $request->getVal( 'wpReason' );
343    }
344
345    /**
346     * @return bool
347     */
348    public function attemptRevert() {
349        $filter = $this->filter;
350        $token = $this->getRequest()->getVal( 'wpEditToken' );
351        if ( !$this->getUser()->matchEditToken( $token, "abusefilter-revert-$filter" ) ) {
352            return false;
353        }
354
355        $results = $this->doLookup();
356        foreach ( $results as $result ) {
357            foreach ( $result['actions'] as $action ) {
358                $this->revertAction( $action, $result );
359            }
360        }
361        $this->getOutput()->addHTML( Html::successBox(
362            $this->msg(
363                'abusefilter-revert-success',
364                $filter,
365                $this->getLanguage()->formatNum( $filter )
366            )->parse()
367        ) );
368
369        return true;
370    }
371
372    /**
373     * Helper method for typing
374     * @param string $action
375     * @param array $result
376     * @return ReversibleConsequence
377     */
378    private function getConsequence( string $action, array $result ): ReversibleConsequence {
379        $params = new Parameters(
380            $this->filterLookup->getFilter( $this->filter, false ),
381            false,
382            $result['spec']
383        );
384
385        switch ( $action ) {
386            case 'block':
387                return $this->consequencesFactory->newBlock( $params, '', false );
388            case 'blockautopromote':
389                $duration = $this->getConfig()->get( 'AbuseFilterBlockAutopromoteDuration' ) * 86400;
390                return $this->consequencesFactory->newBlockAutopromote( $params, $duration );
391            case 'degroup':
392                return $this->consequencesFactory->newDegroup( $params, $result['vars'] );
393            default:
394                throw new UnexpectedValueException( "Invalid action $action" );
395        }
396    }
397
398    /**
399     * @param string $action
400     * @param array $result
401     * @return bool
402     */
403    public function revertAction( string $action, array $result ): bool {
404        $message = $this->msg(
405            'abusefilter-revert-reason', $this->filter, $this->reason
406        )->inContentLanguage()->text();
407
408        $consequence = $this->getConsequence( $action, $result );
409        return $consequence->revert( $this->getUser(), $message );
410    }
411}