Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 193
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
BlockedDomainEditor
0.00% covered (danger)
0.00%
0 / 193
0.00% covered (danger)
0.00%
0 / 10
756
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 msg
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 showList
0.00% covered (danger)
0.00%
0 / 68
0.00% covered (danger)
0.00%
0 / 1
42
 doDomainRow
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 showRemoveForm
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
6
 processRemoveForm
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 showAddForm
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
6
 processAddForm
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 getPageTitle
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\BlockedDomains;
4
5use MediaWiki\Context\IContextSource;
6use MediaWiki\Exception\ErrorPageError;
7use MediaWiki\Exception\PermissionsError;
8use MediaWiki\Html\Html;
9use MediaWiki\HTMLForm\HTMLForm;
10use MediaWiki\Linker\LinkRenderer;
11use MediaWiki\Message\Message;
12use MediaWiki\Title\Title;
13use MediaWiki\Title\TitleValue;
14use Wikimedia\Assert\Assert;
15use Wikimedia\Message\MessageSpecifier;
16use Wikimedia\ObjectCache\WANObjectCache;
17use Wikimedia\Rdbms\IDBAccessObject;
18
19class BlockedDomainEditor {
20
21    public function __construct(
22        private readonly IContextSource $context,
23        private readonly Title $rootTitle,
24        private readonly WANObjectCache $wanCache,
25        private readonly LinkRenderer $linkRenderer,
26        private readonly IBlockedDomainStorage $blockedDomainStorage,
27        private readonly BlockedDomainValidator $blockedDomainValidator
28    ) {
29    }
30
31    /**
32     * Wrapper around wfMessage that sets the current context.
33     *
34     * @param string|string[]|MessageSpecifier $key
35     * @param mixed ...$params
36     * @return Message
37     * @see wfMessage
38     */
39    public function msg( $key, ...$params ) {
40        return $this->context->msg( $key, ...$params );
41    }
42
43    public function execute( ?string $par ) {
44        if ( !$this->context->getConfig()->get( 'AbuseFilterEnableBlockedExternalDomain' ) ) {
45            throw new ErrorPageError( 'abusefilter-disabled', 'disabledspecialpage-disabled' );
46        }
47        $this->context->getOutput()->addHelpLink( 'Manual:BlockedExternalDomains' );
48
49        $request = $this->context->getRequest();
50        match ( $par ) {
51            'remove' => $this->showRemoveForm( $request->getVal( 'domain' ) ),
52            'add' => $this->showAddForm( $request->getVal( 'domain' ) ),
53            default => $this->showList(),
54        };
55    }
56
57    private function showList() {
58        $out = $this->context->getOutput();
59        $out->setPageTitleMsg( $this->msg( 'abusefilter-blocked-domains-title' ) );
60        $out->wrapWikiMsg( "$1", 'abusefilter-blocked-domains-intro' );
61        $out->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
62
63        // Direct editing of this page is blocked via EditPermissionHandler
64        $userCanManage = $this->context->getAuthority()->isAllowed( 'abusefilter-modify-blocked-external-domains' );
65
66        // Show form to add a blocked domain
67        if ( $userCanManage ) {
68            $fields = [
69                'Domain' => [
70                    'type' => 'text',
71                    'label' => $this->msg( 'abusefilter-blocked-domains-domain' )->plain(),
72                    'required' => true,
73                ],
74                'Notes' => [
75                    'type' => 'text',
76                    'maxlength' => 255,
77                    'label' => $this->msg( 'abusefilter-blocked-domains-notes' )->plain(),
78                    'size' => 250,
79                ],
80            ];
81
82            HTMLForm::factory( 'ooui', $fields, $this->context )
83                ->setAction( $this->getPageTitle( 'add' )->getLocalURL() )
84                ->setWrapperLegendMsg( 'abusefilter-blocked-domains-add-heading' )
85                ->setHeaderHtml( $this->msg( 'abusefilter-blocked-domains-add-explanation' )->parseAsBlock() )
86                ->setSubmitCallback( [ $this, 'processAddForm' ] )
87                ->setSubmitTextMsg( 'abusefilter-blocked-domains-add-submit' )
88                ->show();
89
90            if ( $out->getRedirect() !== '' ) {
91                return;
92            }
93        }
94
95        $res = $this->blockedDomainStorage->loadConfig( IDBAccessObject::READ_LATEST );
96        if ( !$res->isGood() ) {
97            return;
98        }
99
100        $content = Html::element( 'th', [], $this->msg( 'abusefilter-blocked-domains-domain-header' )->text() ) .
101            Html::element( 'th', [], $this->msg( 'abusefilter-blocked-domains-notes-header' )->text() );
102        if ( $userCanManage ) {
103            $content .= Html::element(
104                'th',
105                [],
106                $this->msg( 'abusefilter-blocked-domains-addedby-header' )->text()
107            );
108            $content .= Html::element(
109                'th',
110                [ 'class' => 'unsortable' ],
111                $this->msg( 'abusefilter-blocked-domains-actions-header' )->text()
112            );
113        }
114        $thead = Html::rawElement( 'tr', [], $content );
115
116        // Parsing each row is expensive, put it behind WAN cache
117        // with md5 checksum, we make sure changes to the domain list
118        // invalidate the cache
119        $cacheKey = $this->wanCache->makeKey(
120            'abusefilter-blockeddomains-rows',
121            md5( json_encode( $res->getValue() ) ),
122            (int)$userCanManage
123        );
124        $tbody = $this->wanCache->getWithSetCallback(
125            $cacheKey,
126            WANObjectCache::TTL_DAY,
127            function () use ( $res, $userCanManage ) {
128                $tbody = '';
129                foreach ( $res->getValue() as $domain ) {
130                    $tbody .= $this->doDomainRow( $domain, $userCanManage );
131                }
132                return $tbody;
133            }
134        );
135
136        $out->addModuleStyles( [ 'jquery.tablesorter.styles', 'mediawiki.pager.styles' ] );
137        $out->addModules( 'jquery.tablesorter' );
138        $out->addHTML( Html::rawElement(
139            'table',
140            [ 'class' => 'mw-datatable sortable' ],
141            Html::rawElement( 'thead', [], $thead ) .
142            Html::rawElement( 'tbody', [], $tbody )
143        ) );
144    }
145
146    /**
147     * Show the row in the table
148     *
149     * @param array $domain domain data
150     * @param bool $showManageActions whether to add manage actions
151     * @return string HTML for the row
152     */
153    private function doDomainRow( $domain, $showManageActions ) {
154        $newRow = Html::rawElement( 'td', [], Html::element( 'code', [], $domain['domain'] ) );
155
156        $newRow .= Html::rawElement( 'td', [],
157            $this->context->getOutput()->parseInlineAsInterface( $domain['notes'] )
158        );
159
160        if ( $showManageActions ) {
161            if ( isset( $domain['addedBy'] ) ) {
162                $addedBy = $this->linkRenderer->makeLink(
163                    new TitleValue( 3, $domain['addedBy'] ),
164                    $domain['addedBy']
165                );
166            } else {
167                $addedBy = '';
168            }
169            $newRow .= Html::rawElement( 'td', [], $addedBy );
170
171            $actionLink = $this->linkRenderer->makeKnownLink(
172                $this->getPageTitle( 'remove' ),
173                $this->msg( 'abusefilter-blocked-domains-remove' )->text(),
174                [],
175                [ 'domain' => $domain['domain'] ]
176            );
177            $newRow .= Html::rawElement( 'td', [], $actionLink );
178        }
179
180        return Html::rawElement( 'tr', [], $newRow ) . "\n";
181    }
182
183    /**
184     * Show form for removing a domain from the blocked list
185     *
186     * @param string $domain
187     * @return void
188     */
189    private function showRemoveForm( $domain ) {
190        if ( !$this->context->getAuthority()->isAllowed( 'editsitejson' ) ) {
191            throw new PermissionsError( 'editsitejson' );
192        }
193
194        $out = $this->context->getOutput();
195        $out->setPageTitleMsg( $this->msg( 'abusefilter-blocked-domains-remove-title' ) );
196        $out->addBacklinkSubtitle( $this->getPageTitle() );
197
198        $preText = $this->msg( 'abusefilter-blocked-domains-remove-explanation-initial', $domain )->parseAsBlock();
199
200        $fields = [
201            'Domain' => [
202                'type' => 'text',
203                'label' => $this->msg( 'abusefilter-blocked-domains-domain' )->plain(),
204                'required' => true,
205                'default' => $domain,
206            ],
207            'Notes' => [
208                'type' => 'text',
209                'maxlength' => 255,
210                'label' => $this->msg( 'abusefilter-blocked-domains-notes' )->plain(),
211                'size' => 250,
212            ],
213        ];
214
215        HTMLForm::factory( 'ooui', $fields, $this->context )
216            ->setAction( $this->getPageTitle( 'remove' )->getLocalURL() )
217            ->setSubmitCallback( $this->processRemoveForm( ... ) )
218            ->setSubmitTextMsg( 'abusefilter-blocked-domains-remove-submit' )
219            ->setSubmitDestructive()
220            ->addPreHtml( $preText )
221            ->show();
222    }
223
224    /**
225     * Process the form for removing a domain from the blocked list
226     *
227     * @param array $data request data
228     * @param HTMLForm $form
229     * @return bool whether the action was successful or not
230     */
231    public function processRemoveForm( array $data, HTMLForm $form ) {
232        $out = $form->getContext()->getOutput();
233        $domain = $this->blockedDomainValidator->validateDomain( $data['Domain'] );
234        if ( $domain === false ) {
235            $out->addHTML( Html::errorBox(
236                $this->msg( 'http-invalid-url' )->plaintextParams( $data['Domain'] )->parse()
237            ) );
238            return false;
239        }
240
241        $status = $this->blockedDomainStorage->removeDomain(
242            $domain,
243            $data['Notes'] ?? '',
244            $this->context->getAuthority()
245        );
246
247        if ( !$status->isGood() ) {
248            foreach ( $status->getMessages() as $msg ) {
249                $out->addHTML( Html::errorBox( $this->msg( $msg )->parse() ) );
250            }
251            return false;
252        }
253
254        $out->redirect( $this->getPageTitle()->getLocalURL() );
255        return true;
256    }
257
258    /**
259     * Show form for adding a domain to the blocked list
260     *
261     * @param string $domain
262     * @return void
263     */
264    private function showAddForm( $domain ) {
265        if ( !$this->context->getAuthority()->isAllowed( 'editsitejson' ) ) {
266            throw new PermissionsError( 'editsitejson' );
267        }
268
269        $out = $this->context->getOutput();
270        $out->setPageTitleMsg( $this->msg( "abusefilter-blocked-domains-add-heading" ) );
271        $out->addBacklinkSubtitle( $this->getPageTitle() );
272
273        $preText = $this->msg( "abusefilter-blocked-domains-add-explanation", $domain )->parseAsBlock();
274
275        $fields = [
276            'Domain' => [
277                'type' => 'text',
278                'label' => $this->msg( 'abusefilter-blocked-domains-domain' )->plain(),
279                'required' => true,
280                'default' => $domain,
281            ],
282            'Notes' => [
283                'type' => 'text',
284                'maxlength' => 255,
285                'label' => $this->msg( 'abusefilter-blocked-domains-notes' )->plain(),
286                'size' => 250,
287            ],
288        ];
289
290        HTMLForm::factory( 'ooui', $fields, $this->context )
291            ->setAction( $this->getPageTitle( 'add' )->getLocalURL() )
292            ->setSubmitCallback( $this->processAddForm( ... ) )
293            ->setSubmitTextMsg( "abusefilter-blocked-domains-add-submit" )
294            ->addPreHtml( $preText )
295            ->show();
296    }
297
298    /**
299     * Process the form for adding a domain to the blocked list
300     *
301     * @param array $data request data
302     * @param HTMLForm $form
303     * @return bool whether the action was successful or not
304     */
305    private function processAddForm( array $data, HTMLForm $form ) {
306        $out = $form->getContext()->getOutput();
307
308        $domain = $this->blockedDomainValidator->validateDomain( $data['Domain'] );
309        if ( $domain === false ) {
310            $out->addHTML( Html::errorBox(
311                $this->msg( 'http-invalid-url' )->plaintextParams( $data['Domain'] )->parse()
312            ) );
313            return false;
314        }
315        $status = $this->blockedDomainStorage->addDomain(
316            $domain,
317            $data['Notes'] ?? '',
318            $this->context->getAuthority()
319        );
320
321        if ( !$status->isGood() ) {
322            foreach ( $status->getMessages() as $msg ) {
323                $out->addHTML( Html::errorBox( $this->msg( $msg )->parse() ) );
324            }
325            return false;
326        }
327
328        $out->redirect( $this->getPageTitle()->getLocalURL() );
329        return true;
330    }
331
332    private function getPageTitle( ?string $subpage = null ): Title {
333        $title = $this->rootTitle;
334        if ( $subpage !== null ) {
335            $title = $title->getSubpage( $subpage );
336        }
337        Assert::invariant( $title !== null, 'A valid title is expected' );
338        return $title;
339    }
340}