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