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