Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
202 / 202
100.00% covered (success)
100.00%
14 / 14
CRAP
100.00% covered (success)
100.00%
1 / 1
SpecialGlobalWatchlistSettings
100.00% covered (success)
100.00%
202 / 202
100.00% covered (success)
100.00%
14 / 14
27
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 newFromGlobalState
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getFormFields
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
3
 maybeLoadTour
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 maybeGetValidSites
n/a
0 / 0
n/a
0 / 0
5
 getDisplayFormat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMessagePrefix
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 alterForm
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getActualFormFields
100.00% covered (success)
100.00%
96 / 96
100.00% covered (success)
100.00%
1 / 1
5
 onSubmit
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
1
 onSuccess
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doesWrites
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGroupName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isListed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Implements Special:GlobalWatchlistSettings
5 *
6 * This program is free software; you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation; either version 2 of the License, or
9 * (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License along
17 * with this program; if not, write to the Free Software Foundation, Inc.,
18 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 * http://www.gnu.org/copyleft/gpl.html
20 *
21 * @file
22 * @ingroup SpecialPage
23 */
24
25namespace MediaWiki\Extension\GlobalWatchlist;
26
27use ExtensionRegistry;
28use FormatJson;
29use HTMLForm;
30use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
31use MediaWiki\Logger\LoggerFactory;
32use MediaWiki\SpecialPage\FormSpecialPage;
33use MediaWiki\SpecialPage\SpecialPageFactory;
34use MediaWiki\Status\Status;
35use MediaWiki\User\Options\UserOptionsLookup;
36use MediaWiki\WikiMap\WikiMap;
37use Psr\Log\LoggerInterface;
38
39/**
40 * @ingroup SpecialPage
41 * @author DannyS712
42 */
43class SpecialGlobalWatchlistSettings extends FormSpecialPage {
44
45    /** @var LoggerInterface */
46    private $logger;
47
48    /** @var ExtensionRegistry */
49    private $extensionRegistry;
50
51    /** @var SettingsManager */
52    private $settingsManager;
53
54    /** @var SpecialPageFactory */
55    private $specialPageFactory;
56
57    /** @var UserOptionsLookup */
58    private $userOptionsLookup;
59
60    /**
61     * @param LoggerInterface $logger
62     * @param ExtensionRegistry $extensionRegistry
63     * @param SettingsManager $settingsManager
64     * @param SpecialPageFactory $specialPageFactory
65     * @param UserOptionsLookup $userOptionsLookup
66     */
67    public function __construct(
68        LoggerInterface $logger,
69        ExtensionRegistry $extensionRegistry,
70        SettingsManager $settingsManager,
71        SpecialPageFactory $specialPageFactory,
72        UserOptionsLookup $userOptionsLookup
73    ) {
74        parent::__construct( 'GlobalWatchlistSettings', 'editmyoptions' );
75
76        $this->logger = $logger;
77        $this->extensionRegistry = $extensionRegistry;
78        $this->settingsManager = $settingsManager;
79        $this->specialPageFactory = $specialPageFactory;
80        $this->userOptionsLookup = $userOptionsLookup;
81    }
82
83    /**
84     * Need a factory method to inject LoggerInstance and ExtensionRegistry,
85     * which are not available from the service container
86     *
87     * @param SettingsManager $settingsManager
88     * @param SpecialPageFactory $specialPageFactory
89     * @param UserOptionsLookup $userOptionsLookup
90     * @return SpecialGlobalWatchlistSettings
91     */
92    public static function newFromGlobalState(
93        SettingsManager $settingsManager,
94        SpecialPageFactory $specialPageFactory,
95        UserOptionsLookup $userOptionsLookup
96    ) {
97        return new SpecialGlobalWatchlistSettings(
98            LoggerFactory::getInstance( 'GlobalWatchlist' ),
99            ExtensionRegistry::getInstance(),
100            $settingsManager,
101            $specialPageFactory,
102            $userOptionsLookup
103        );
104    }
105
106    /**
107     * @param string|null $par
108     */
109    public function execute( $par ) {
110        $this->addHelpLink( 'Extension:GlobalWatchlist' );
111
112        $this->requireNamedUser( 'globalwatchlist-must-login' );
113
114        $this->getOutput()->addModules( 'mediawiki.htmlform.ooui' );
115        $this->getOutput()->addModuleStyles(
116            'ext.globalwatchlist.specialglobalwatchlistsettings'
117        );
118
119        parent::execute( $par );
120    }
121
122    /**
123     * Get an HTMLForm descriptor array
124     * @return array
125     */
126    protected function getFormFields() {
127        $currentOptions = $this->userOptionsLookup->getOption(
128            $this->getUser(),
129            SettingsManager::PREFERENCE_NAME,
130            false
131        );
132
133        // This should be kept in sync with getSettings.js in terms of defaults
134        $server = $this->getConfig()->get( 'Server' );
135        $defaultOptions = [
136            'sites' => [
137                preg_replace( '/.*?\/\//', '', $server )
138            ],
139            'anonfilter' => SettingsManager::FILTER_EITHER,
140            'botfilter' => SettingsManager::FILTER_EITHER,
141            'minorfilter' => SettingsManager::FILTER_EITHER,
142            'confirmallsites' => true,
143            'fastmode' => false,
144            'grouppage' => true,
145            'showtypes' => [ 'edit', 'log', 'new' ],
146        ];
147
148        if ( $currentOptions === false ) {
149            $userOptions = $defaultOptions;
150            $this->maybeLoadTour();
151        } else {
152            // User has options, try to handle them
153            $parsedOptions = FormatJson::parse( $currentOptions );
154            if ( $parsedOptions->isGood() ) {
155                $userOptions = (array)$parsedOptions->getValue();
156            } else {
157                // Alert the user that their settings couldn't be used
158                $this->getOutput()->addModules(
159                    'ext.globalwatchlist.getsettingserror'
160                );
161
162                $userOptions = $defaultOptions;
163            }
164        }
165
166        $formValidator = new SettingsFormValidator(
167            $this->getContext(),
168            $this->getConfig()->get( 'GlobalWatchlistSiteLimit' ),
169            $this->maybeGetValidSites()
170        );
171
172        return $this->getActualFormFields( $formValidator, $userOptions );
173    }
174
175    /**
176     * If $wgGlobalWatchlistEnableGuidedTour is true, and the GuidedTour extension is available,
177     * load the tour for the settings page
178     *
179     * Only called if the user does not currently have any settings saved (i.e. is a new user)
180     */
181    private function maybeLoadTour() {
182        if (
183            $this->getConfig()->get( 'GlobalWatchlistEnableGuidedTour' ) &&
184            $this->extensionRegistry->isLoaded( 'GuidedTour' )
185        ) {
186            $this->getOutput()->addModules(
187                'ext.guidedTour.globalWatchlistSettings'
188            );
189        }
190    }
191
192    /**
193     * Used to validate the site list provided against the wikis a user has an attached
194     * account on, if CentralAuth is available. If not, there is no validation.
195     *
196     * @codeCoverageIgnore
197     * @return ?array either an array of the sites that are okay, or null for no validation
198     */
199    private function maybeGetValidSites(): ?array {
200        if ( !$this->extensionRegistry->isLoaded( 'CentralAuth' ) ) {
201            $this->logger->debug( 'CentralAuth is not installed, no site validation' );
202            return null;
203        }
204        $this->logger->debug( 'CentralAuth is installed, validating against attached wikis' );
205        $attachedWikis = CentralAuthUser::getInstance( $this->getUser() )->listAttached();
206
207        return array_map(
208            static function ( $dbName ) {
209                $wiki = WikiMap::getWiki( $dbName );
210                if ( !$wiki ) {
211                    // This should never happen, but just in case
212                    return '';
213                }
214                // WikiReference::getDisplayName() only returns the 'host' for
215                // the server url, but we need to also handle sites that include
216                // a port at the end, eg Vagrant wikis. See T289384
217                $bits = wfParseUrl( $wiki->getCanonicalServer() );
218                if ( !$bits ) {
219                    // Match behavior of WikiReference::getDisplayName()
220                    // Invalid server spec.
221                    // There's no sane thing to do here, so just return the canonical server name in full.
222                    return $wiki->getCanonicalServer();
223                }
224                $url = $bits['host'];
225                if ( isset( $bits['port'] ) ) {
226                    $url .= ':' . $bits['port'];
227                }
228                return $url;
229            },
230            $attachedWikis
231        );
232    }
233
234    /**
235     * Display form as OOUI
236     *
237     * @return string
238     */
239    protected function getDisplayFormat() {
240        return 'ooui';
241    }
242
243    /**
244     * Get message prefix for HTMLForm
245     *
246     * @return string
247     */
248    protected function getMessagePrefix() {
249        return 'globalwatchlist';
250    }
251
252    /**
253     * Set correct label for submit button
254     *
255     * @param HTMLForm $form
256     */
257    protected function alterForm( HTMLForm $form ) {
258        $form->setSubmitText(
259            $this->msg( 'globalwatchlist-save' )->escaped()
260        );
261
262        // Enable cancel button, target is Special:GlobalWatchlist
263        // See T268259
264        $globalWatchlistSpecial = $this->specialPageFactory->getPage( 'GlobalWatchlist' );
265        $form->showCancel();
266        $form->setCancelTarget( $globalWatchlistSpecial->getPageTitle() );
267    }
268
269    /**
270     * Get form fields with defaults filled in based on $userOptions
271     *
272     * @param SettingsFormValidator $formValidator
273     * @param array $userOptions
274     * @return array
275     */
276    private function getActualFormFields(
277        SettingsFormValidator $formValidator,
278        array $userOptions
279    ): array {
280        $fields = [];
281
282        // Due to the "implicit submission" feature of html forms, hitting enter
283        // triggers the first submit button in the form - add an extra button at
284        // the start, so that the button that is triggered is not the button
285        // to remove the first site row from HTMLFormFieldCloner. Put that button
286        // in the "sitelist" section so that it goes on top, and disable it so that
287        // the "implicit submission" attempt doesn't actually submit the form.
288        // We hide the button from the viewer via the css class, see styles in
289        // the SpecialGlobalWatchlistSettings.css file. See T275588 for more
290        // Known issue: only fixes the "implicit submission" from hitting enter
291        // if the user is focused on one of the site rows, elsewhere in the form
292        // enter still triggers the removal of the top site row (or the addition
293        // of a new row if there are no existing rows).
294        $fields['fake-submit'] = [
295            'type' => 'submit',
296            'disabled' => true,
297            'section' => 'sitelist',
298            'cssclass' => 'ext-globalwatchlist-settings-fakesubmit',
299        ];
300
301        // ******** Site rows *******
302        $siteFields = [
303            'site' => [
304                'type' => 'text',
305                'size' => 200,
306            ],
307            'delete' => [
308                'type' => 'submit',
309                'default' => $this->msg( 'globalwatchlist-remove' )->escaped(),
310                'flags' => [ 'destructive' ],
311            ],
312        ];
313        $sitesDefault = [];
314        foreach ( $userOptions['sites'] as $defaultSite ) {
315            $sitesDefault[] = [ 'site' => $defaultSite ];
316        }
317        $sitesDefault[] = [ 'site' => null ];
318        $fields['sites'] = [
319            'type' => 'cloner',
320            'fields' => $siteFields,
321            'section' => 'sitelist',
322            'validation-callback' => [ $formValidator, 'validateSitesChosen' ],
323            'default' => $sitesDefault,
324            'create-button-message' => 'globalwatchlist-add',
325        ];
326
327        // ******** Filters ********
328        $fields['anon'] = [
329            'type' => 'radio',
330            'options' => [
331                $this->msg( 'globalwatchlist-filter-either' )->escaped() => SettingsManager::FILTER_EITHER,
332                $this->msg( 'globalwatchlist-filter-only-anon' )->escaped() => SettingsManager::FILTER_REQUIRE,
333                $this->msg( 'globalwatchlist-filter-not-anon' )->escaped() => SettingsManager::FILTER_EXCLUDE,
334            ],
335            'label-message' => 'globalwatchlist-filter-anon',
336            'section' => 'filters',
337            'default' => $userOptions['anonfilter'],
338        ];
339        $fields['bot'] = [
340            'type' => 'radio',
341            'options' => [
342                $this->msg( 'globalwatchlist-filter-either' )->escaped() => SettingsManager::FILTER_EITHER,
343                $this->msg( 'globalwatchlist-filter-only-bot' )->escaped() => SettingsManager::FILTER_REQUIRE,
344                $this->msg( 'globalwatchlist-filter-not-bot' )->escaped() => SettingsManager::FILTER_EXCLUDE,
345            ],
346            'label-message' => 'globalwatchlist-filter-bot',
347            'validation-callback' => [ $formValidator, 'validateAnonBot' ],
348            'section' => 'filters',
349            'default' => $userOptions['botfilter'],
350        ];
351        $fields['minor'] = [
352            'type' => 'radio',
353            'options' => [
354                $this->msg( 'globalwatchlist-filter-either' )->escaped() => SettingsManager::FILTER_EITHER,
355                $this->msg( 'globalwatchlist-filter-only-minor' )->escaped() => SettingsManager::FILTER_REQUIRE,
356                $this->msg( 'globalwatchlist-filter-not-minor' )->escaped() => SettingsManager::FILTER_EXCLUDE,
357            ],
358            'label-message' => 'globalwatchlist-filter-minor',
359            'validation-callback' => [ $formValidator, 'validateAnonMinor' ],
360            'section' => 'filters',
361            'default' => $userOptions['minorfilter'],
362        ];
363
364        // ********** Other options ********
365        $fields['types'] = [
366            'type' => 'multiselect',
367            'label-message' => 'globalwatchlist-changetypes',
368            'options' => [
369                $this->msg( 'globalwatchlist-show-edits' )->escaped() => 'edit',
370                $this->msg( 'globalwatchlist-show-logentries' )->escaped() => 'log',
371                $this->msg( 'globalwatchlist-show-newpages' )->escaped() => 'new',
372            ],
373            'validation-callback' => [ $formValidator, 'requireShowingOneType' ],
374            'section' => 'otheroptions',
375            'default' => $userOptions['showtypes'],
376        ];
377
378        $otherOptionsDefaults = [];
379        if ( $userOptions['grouppage'] ) {
380            $otherOptionsDefaults[] = 'grouppage';
381        }
382        if ( $userOptions['confirmallsites'] ) {
383            $otherOptionsDefaults[] = 'confirmallsites';
384        }
385        if ( $userOptions['fastmode'] ) {
386            $otherOptionsDefaults[] = 'fastmode';
387        }
388        $fields['otheroptions'] = [
389            'type' => 'multiselect',
390            'label-message' => 'globalwatchlist-otheroptions',
391            'options' => [
392                $this->msg( 'globalwatchlist-option-grouppage' )->escaped() => 'grouppage',
393                $this->msg( 'globalwatchlist-option-confirmallsites' )->escaped() => 'confirmallsites',
394                $this->msg( 'globalwatchlist-option-fastmode' )->escaped() => 'fastmode',
395            ],
396            'section' => 'otheroptions',
397            'default' => $otherOptionsDefaults,
398        ];
399
400        return $fields;
401    }
402
403    /**
404     * @param array $data
405     * @param HTMLForm|null $form
406     * @return bool|string|array|Status
407     */
408    public function onSubmit( array $data, HTMLForm $form = null ) {
409        $this->logger->info(
410            "Settings form submitted with {options}",
411            [
412                'options' => FormatJson::encode( $data )
413            ]
414        );
415
416        $sites = array_map(
417            static function ( $row ) {
418                // Accept and handle sites with a protocol, see T262762
419                return preg_replace( '/^(?:https?:)?\/\//', '', trim( $row['site'] ) );
420            },
421            $data['sites']
422        );
423
424        // Use array_values to ensure we don't save the keys if there are empty sites,
425        // keys don't matter and take up space in the database
426        $sites = array_values(
427            array_filter(
428                $sites,
429                static function ( $site ) {
430                    return ( $site !== '' );
431                }
432            )
433        );
434
435        $userOptions = [
436            'sites' => $sites,
437            'anonfilter' => (int)$data['anon'],
438            'botfilter' => (int)$data['bot'],
439            'minorfilter' => (int)$data['minor'],
440            'confirmallsites' => in_array( 'confirmallsites', $data['otheroptions'] ),
441            'fastmode' => in_array( 'fastmode', $data['otheroptions'] ),
442            'grouppage' => in_array( 'grouppage', $data['otheroptions'] ),
443            'showtypes' => $data['types'],
444        ];
445
446        $this->settingsManager->saveUserOptions(
447            $this->getUser(),
448            $userOptions
449        );
450        return true;
451    }
452
453    /**
454     * Settings were saved successfully
455     */
456    public function onSuccess() {
457        $this->getOutput()->addWikiMsg( 'globalwatchlist-notify-settingssaved' );
458    }
459
460    /**
461     * @return bool
462     */
463    public function doesWrites() {
464        return true;
465    }
466
467    /**
468     * @return string
469     */
470    protected function getGroupName() {
471        return 'changes';
472    }
473
474    /**
475     * Only shown for logged in users
476     *
477     * @return bool
478     */
479    public function isListed() {
480        return $this->getUser()->isNamed();
481    }
482
483}