Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.89% covered (warning)
88.89%
112 / 126
61.54% covered (warning)
61.54%
8 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiOptions
88.89% covered (warning)
88.89%
112 / 126
61.54% covered (warning)
61.54%
8 / 13
44.42
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 execute
97.56% covered (success)
97.56%
80 / 82
0.00% covered (danger)
0.00%
0 / 1
29
 getUserForUpdates
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getPreferences
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 resetPreferences
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setPreference
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 commitChanges
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mustBePosted
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isWriteMode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAllowedParams
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 needsToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHelpUrls
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Copyright © 2012 Szymon Świerkosz beau@adres.pl
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23use MediaWiki\Logger\LoggerFactory;
24use MediaWiki\MediaWikiServices;
25use MediaWiki\Preferences\DefaultPreferencesFactory;
26use MediaWiki\Preferences\PreferencesFactory;
27use MediaWiki\User\Options\UserOptionsManager;
28use MediaWiki\User\User;
29use Wikimedia\ParamValidator\ParamValidator;
30
31/**
32 * API module that facilitates the changing of user's preferences.
33 * Requires API write mode to be enabled.
34 *
35 * @ingroup API
36 */
37class ApiOptions extends ApiBase {
38    /** @var User User account to modify */
39    private $userForUpdates;
40
41    private UserOptionsManager $userOptionsManager;
42    private PreferencesFactory $preferencesFactory;
43
44    /**
45     * @param ApiMain $main
46     * @param string $action
47     * @param UserOptionsManager|null $userOptionsManager
48     * @param PreferencesFactory|null $preferencesFactory
49     */
50    public function __construct(
51        ApiMain $main,
52        $action,
53        UserOptionsManager $userOptionsManager = null,
54        PreferencesFactory $preferencesFactory = null
55    ) {
56        parent::__construct( $main, $action );
57        /**
58         * This class is extended by GlobalPreferences extension.
59         * So it falls back to the global state.
60         */
61        $services = MediaWikiServices::getInstance();
62        $this->userOptionsManager = $userOptionsManager ?? $services->getUserOptionsManager();
63        $this->preferencesFactory = $preferencesFactory ?? $services->getPreferencesFactory();
64    }
65
66    /**
67     * Changes preferences of the current user.
68     */
69    public function execute() {
70        $user = $this->getUserForUpdates();
71        if ( !$user || !$user->isNamed() ) {
72            $this->dieWithError(
73                [ 'apierror-mustbeloggedin', $this->msg( 'action-editmyoptions' ) ], 'notloggedin'
74            );
75        }
76
77        $this->checkUserRightsAny( 'editmyoptions' );
78
79        $params = $this->extractRequestParams();
80        $changed = false;
81
82        if ( isset( $params['optionvalue'] ) && !isset( $params['optionname'] ) ) {
83            $this->dieWithError( [ 'apierror-missingparam', 'optionname' ] );
84        }
85
86        $resetKinds = $params['resetkinds'];
87        if ( !$params['reset'] ) {
88            $resetKinds = [];
89        }
90
91        $changes = [];
92        if ( $params['change'] ) {
93            foreach ( $params['change'] as $entry ) {
94                $array = explode( '=', $entry, 2 );
95                $changes[$array[0]] = $array[1] ?? null;
96            }
97        }
98        if ( isset( $params['optionname'] ) ) {
99            $newValue = $params['optionvalue'] ?? null;
100            $changes[$params['optionname']] = $newValue;
101        }
102
103        $this->getHookRunner()->onApiOptions( $this, $user, $changes, $resetKinds );
104
105        if ( $resetKinds ) {
106            $this->resetPreferences( $resetKinds );
107            $changed = true;
108        }
109
110        if ( !$changed && !count( $changes ) ) {
111            $this->dieWithError( 'apierror-nochanges' );
112        }
113
114        $prefs = $this->getPreferences();
115        $prefsKinds = $this->userOptionsManager->getOptionKinds( $user, $this->getContext(), $changes );
116
117        $htmlForm = new HTMLForm( DefaultPreferencesFactory::simplifyFormDescriptor( $prefs ), $this );
118        foreach ( $changes as $key => $value ) {
119            switch ( $prefsKinds[$key] ) {
120                case 'registered':
121                    // Regular option.
122                    if ( $value === null ) {
123                        // Reset it
124                        $validation = true;
125                    } else {
126                        // Validate
127                        $field = $htmlForm->getField( $key );
128                        $validation = $field->validate( $value, $this->userOptionsManager->getOptions( $user ) );
129                    }
130                    break;
131                case 'registered-multiselect':
132                case 'registered-checkmatrix':
133                    // A key for a multiselect or checkmatrix option.
134                    // TODO: Apply validation properly.
135                    $validation = true;
136                    $value = $value !== null ? (bool)$value : null;
137                    break;
138                case 'userjs':
139                    // Allow non-default preferences prefixed with 'userjs-', to be set by user scripts
140                    if ( strlen( $key ) > 255 ) {
141                        $validation = $this->msg( 'apiwarn-validationfailed-keytoolong', Message::numParam( 255 ) );
142                    } elseif ( preg_match( '/[^a-zA-Z0-9_-]/', $key ) !== 0 ) {
143                        $validation = $this->msg( 'apiwarn-validationfailed-badchars' );
144                    } else {
145                        $validation = true;
146                    }
147
148                    LoggerFactory::getInstance( 'api-warning' )->info(
149                        'ApiOptions: Setting userjs option',
150                        [
151                            'phab' => 'T259073',
152                            'OptionName' => substr( $key, 0, 255 ),
153                            'OptionValue' => substr( $value ?? '', 0, 255 ),
154                            'OptionSize' => strlen( $value ?? '' ),
155                            'OptionValidation' => $validation,
156                            'UserId' => $user->getId(),
157                            'RequestIP' => $this->getRequest()->getIP(),
158                            'RequestUA' => $this->getRequest()->getHeader( 'User-Agent' )
159                        ]
160                    );
161                    break;
162                case 'special':
163                    $validation = $this->msg( 'apiwarn-validationfailed-cannotset' );
164                    break;
165                case 'unused':
166                default:
167                    $validation = $this->msg( 'apiwarn-validationfailed-badpref' );
168                    break;
169            }
170            if ( $validation === true && is_string( $value ) &&
171                strlen( $value ) > UserOptionsManager::MAX_BYTES_OPTION_VALUE
172            ) {
173                $validation = $this->msg(
174                    'apiwarn-validationfailed-valuetoolong',
175                    Message::numParam( UserOptionsManager::MAX_BYTES_OPTION_VALUE )
176                );
177            }
178            if ( $validation === true ) {
179                $this->setPreference( $key, $value );
180                $changed = true;
181            } else {
182                $this->addWarning( [ 'apiwarn-validationfailed', wfEscapeWikiText( $key ), $validation ] );
183            }
184        }
185
186        if ( $changed ) {
187            $this->commitChanges();
188        }
189
190        $this->getResult()->addValue( null, $this->getModuleName(), 'success' );
191    }
192
193    /**
194     * Load the user from the primary to reduce CAS errors on double post (T95839)
195     *
196     * @return User|null
197     */
198    protected function getUserForUpdates() {
199        if ( !$this->userForUpdates ) {
200            $this->userForUpdates = $this->getUser()->getInstanceForUpdate();
201        }
202
203        return $this->userForUpdates;
204    }
205
206    /**
207     * Returns preferences form descriptor
208     * @return mixed[][]
209     */
210    protected function getPreferences() {
211        return $this->preferencesFactory->getFormDescriptor( $this->getUserForUpdates(),
212            $this->getContext() );
213    }
214
215    /**
216     * @param string[] $kinds One or more types returned by UserOptionsManager::listOptionKinds() or 'all'
217     */
218    protected function resetPreferences( array $kinds ) {
219        $this->userOptionsManager->resetOptions( $this->getUserForUpdates(), $this->getContext(), $kinds );
220    }
221
222    /**
223     * Sets one user preference to be applied by commitChanges()
224     *
225     * @param string $preference
226     * @param mixed $value
227     */
228    protected function setPreference( $preference, $value ) {
229        $this->userOptionsManager->setOption( $this->getUserForUpdates(), $preference, $value );
230    }
231
232    /**
233     * Applies changes to user preferences
234     */
235    protected function commitChanges() {
236        $this->getUserForUpdates()->saveSettings();
237    }
238
239    public function mustBePosted() {
240        return true;
241    }
242
243    public function isWriteMode() {
244        return true;
245    }
246
247    public function getAllowedParams() {
248        $optionKinds = $this->userOptionsManager->listOptionKinds();
249        $optionKinds[] = 'all';
250
251        return [
252            'reset' => false,
253            'resetkinds' => [
254                ParamValidator::PARAM_TYPE => $optionKinds,
255                ParamValidator::PARAM_DEFAULT => 'all',
256                ParamValidator::PARAM_ISMULTI => true
257            ],
258            'change' => [
259                ParamValidator::PARAM_ISMULTI => true,
260            ],
261            'optionname' => [
262                ParamValidator::PARAM_TYPE => 'string',
263            ],
264            'optionvalue' => [
265                ParamValidator::PARAM_TYPE => 'string',
266            ],
267        ];
268    }
269
270    public function needsToken() {
271        return 'csrf';
272    }
273
274    public function getHelpUrls() {
275        return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Options';
276    }
277
278    protected function getExamplesMessages() {
279        return [
280            'action=options&reset=&token=123ABC'
281                => 'apihelp-options-example-reset',
282            'action=options&change=skin=vector|hideminor=1&token=123ABC'
283                => 'apihelp-options-example-change',
284            'action=options&reset=&change=skin=monobook&optionname=nickname&' .
285                'optionvalue=[[User:Beau|Beau]]%20([[User_talk:Beau|talk]])&token=123ABC'
286                => 'apihelp-options-example-complex',
287        ];
288    }
289}