Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 149
0.00% covered (danger)
0.00%
0 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiOptionsBase
0.00% covered (danger)
0.00%
0 / 148
0.00% covered (danger)
0.00%
0 / 17
2652
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
240
 runHook
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 shouldIgnoreKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPrefsKinds
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHtmlForm
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 validate
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
272
 countUserJsOptions
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getUserFromPrimary
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUserFromPrimaryOrNull
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getPreferences
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getUserOptionsManager
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPreferencesFactory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 resetPreferences
n/a
0 / 0
n/a
0 / 0
0
 setPreference
n/a
0 / 0
n/a
0 / 0
0
 commitChanges
n/a
0 / 0
n/a
0 / 0
0
 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
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
2
 needsToken
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Copyright © 2012 Szymon Świerkosz beau@adres.pl
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 */
8
9namespace MediaWiki\Api;
10
11use MediaWiki\HTMLForm\HTMLForm;
12use MediaWiki\Logger\LoggerFactory;
13use MediaWiki\MainConfigNames;
14use MediaWiki\Message\Message;
15use MediaWiki\Preferences\DefaultPreferencesFactory;
16use MediaWiki\Preferences\PreferencesFactory;
17use MediaWiki\User\Options\UserOptionsLookup;
18use MediaWiki\User\Options\UserOptionsManager;
19use MediaWiki\User\User;
20use Wikimedia\ParamValidator\ParamValidator;
21
22/**
23 * The base class for core's ApiOptions and two modules in the GlobalPreferences
24 * extension.
25 *
26 * @ingroup API
27 */
28abstract class ApiOptionsBase extends ApiBase {
29    /** @var User|null User account to modify */
30    private ?User $userFromPrimary = null;
31
32    private UserOptionsManager $userOptionsManager;
33    private PreferencesFactory $preferencesFactory;
34
35    /** @var mixed[][]|null */
36    private $preferences;
37
38    /** @var HTMLForm|null */
39    private $htmlForm;
40
41    /** @var string[]|null */
42    private $prefsKinds;
43    private int $userJsLimit;
44
45    public function __construct(
46        ApiMain $main,
47        string $action,
48        UserOptionsManager $userOptionsManager,
49        PreferencesFactory $preferencesFactory
50    ) {
51        parent::__construct( $main, $action );
52        $this->userOptionsManager = $userOptionsManager;
53        $this->preferencesFactory = $preferencesFactory;
54        $this->userJsLimit = $this->getConfig()->get( MainConfigNames::UserJsPrefLimit );
55    }
56
57    /**
58     * Changes preferences of the current user.
59     */
60    public function execute() {
61        $user = $this->getUser();
62        if ( !$user->isNamed() ) {
63            $this->dieWithError(
64                [ 'apierror-mustbeloggedin', $this->msg( 'action-editmyoptions' ) ], 'notloggedin'
65            );
66        }
67
68        $this->checkUserRightsAny( 'editmyoptions' );
69
70        $params = $this->extractRequestParams();
71        $changed = false;
72
73        if ( isset( $params['optionvalue'] ) && !isset( $params['optionname'] ) ) {
74            $this->dieWithError( [ 'apierror-missingparam', 'optionname' ] );
75        }
76
77        $resetKinds = $params['resetkinds'];
78        if ( !$params['reset'] ) {
79            $resetKinds = [];
80        }
81
82        $changes = [];
83        if ( $params['change'] ) {
84            foreach ( $params['change'] as $entry ) {
85                $array = explode( '=', $entry, 2 );
86                $changes[$array[0]] = $array[1] ?? null;
87            }
88        }
89        if ( isset( $params['optionname'] ) ) {
90            $newValue = $params['optionvalue'] ?? null;
91            $changes[$params['optionname']] = $newValue;
92        }
93
94        $this->runHook( $user, $changes, $resetKinds );
95
96        if ( $resetKinds ) {
97            $this->resetPreferences( $resetKinds );
98            $changed = true;
99        }
100
101        if ( !$changed && !count( $changes ) ) {
102            $this->dieWithError( 'apierror-nochanges' );
103        }
104
105        $this->prefsKinds = $this->preferencesFactory->getResetKinds( $user, $this->getContext(), $changes );
106
107        foreach ( $changes as $key => $value ) {
108            if ( $this->shouldIgnoreKey( $key ) ) {
109                continue;
110            }
111            $validation = $this->validate( $key, $value );
112            if ( $validation === true ) {
113                $this->setPreference( $key, $value );
114                $changed = true;
115            } else {
116                $this->addWarning( [ 'apiwarn-validationfailed', wfEscapeWikiText( $key ), $validation ] );
117            }
118        }
119
120        if ( $changed ) {
121            $this->commitChanges();
122        }
123
124        $this->getResult()->addValue( null, $this->getModuleName(), 'success' );
125    }
126
127    /**
128     * Run the ApiOptions hook if applicable
129     *
130     * @param User $user
131     * @param string[] $changes
132     * @param string[] $resetKinds
133     */
134    protected function runHook( $user, $changes, $resetKinds ) {
135    }
136
137    /**
138     * Check whether a key should be ignored.
139     *
140     * This may be overridden to emit a warning as well as returning true.
141     *
142     * @param string $key
143     * @return bool
144     */
145    protected function shouldIgnoreKey( $key ) {
146        return false;
147    }
148
149    /**
150     * Get the preference kinds for the current user's options.
151     * This can only be called after $this->prefsKinds is set in execute()
152     *
153     * @return string[]
154     */
155    protected function getPrefsKinds(): array {
156        return $this->prefsKinds;
157    }
158
159    /**
160     * Get the HTMLForm for the user's preferences
161     *
162     * @return HTMLForm
163     */
164    protected function getHtmlForm() {
165        if ( !$this->htmlForm ) {
166            $this->htmlForm = new HTMLForm(
167                DefaultPreferencesFactory::simplifyFormDescriptor( $this->getPreferences() ),
168                $this
169            );
170        }
171        return $this->htmlForm;
172    }
173
174    /**
175     * Validate a proposed change
176     *
177     * @param string $key
178     * @param mixed &$value
179     * @return bool|\MediaWiki\Message\Message|string
180     */
181    protected function validate( $key, &$value ) {
182        switch ( $this->getPrefsKinds()[$key] ) {
183            case 'registered':
184                // Regular option.
185                if ( $value === null ) {
186                    // Reset it
187                    $validation = true;
188                } else {
189                    // Validate
190                    $field = $this->getHtmlForm()->getField( $key );
191                    $validation = $field->validate(
192                        $value,
193                        $this->userOptionsManager->getOptions( $this->getUser() )
194                    );
195                }
196                break;
197            case 'registered-multiselect':
198            case 'registered-checkmatrix':
199                // A key for a multiselect or checkmatrix option.
200                // TODO: Apply validation properly.
201                $validation = true;
202                $value = $value !== null ? (bool)$value : null;
203                break;
204            case 'userjs':
205                // Allow non-default preferences prefixed with 'userjs-', to be set by user scripts
206                if ( strlen( $key ) > 255 ) {
207                    $validation = $this->msg( 'apiwarn-validationfailed-keytoolong', Message::numParam( 255 ) );
208                } elseif ( preg_match( '/[^a-zA-Z0-9_-]/', $key ) !== 0 ) {
209                    $validation = $this->msg( 'apiwarn-validationfailed-badchars' );
210                } elseif ( $this->countUserJsOptions() >= $this->userJsLimit ) {
211                    $validation = $this->msg(
212                        'apiwarn-validationfailed-toomanyuserjs',
213                        Message::numParam( $this->userJsLimit )
214                    );
215                } else {
216                    $validation = true;
217                }
218
219                LoggerFactory::getInstance( 'api-warning' )->info(
220                    'ApiOptions: Setting userjs option',
221                    [
222                        'phab' => 'T259073',
223                        'OptionName' => substr( $key, 0, 255 ),
224                        'OptionValue' => substr( $value ?? '', 0, 255 ),
225                        'OptionSize' => strlen( $value ?? '' ),
226                        'OptionValidation' => $validation,
227                        'UserId' => $this->getUser()->getId(),
228                        'RequestIP' => $this->getRequest()->getIP(),
229                        'RequestUA' => $this->getRequest()->getHeader( 'User-Agent' )
230                    ]
231                );
232                break;
233            case 'special':
234                $validation = $this->msg( 'apiwarn-validationfailed-cannotset' );
235                break;
236            case 'unused':
237            default:
238                $validation = $this->msg( 'apiwarn-validationfailed-badpref' );
239                break;
240        }
241        if ( $validation === true && is_string( $value ) &&
242            strlen( $value ) > UserOptionsManager::MAX_BYTES_OPTION_VALUE
243        ) {
244            $validation = $this->msg(
245                'apiwarn-validationfailed-valuetoolong',
246                Message::numParam( UserOptionsManager::MAX_BYTES_OPTION_VALUE )
247            );
248        }
249        return $validation;
250    }
251
252    private function countUserJsOptions(): int {
253        $options = $this->userOptionsManager->getOptions(
254            $this->getUser(),
255            UserOptionsLookup::EXCLUDE_DEFAULTS
256        );
257        $userJsCount = 0;
258        foreach ( $options as $prefName => $value ) {
259            if ( str_starts_with( $prefName, 'userjs-' ) ) {
260                $userJsCount += 1;
261            }
262        }
263        return $userJsCount;
264    }
265
266    /**
267     * Load the user from the primary to reduce CAS errors on double post (T95839)
268     * Will throw if the user is anonymous.
269     */
270    protected function getUserFromPrimary(): User {
271        // @phan-suppress-next-line PhanTypeMismatchReturnNullable
272        return $this->getUserFromPrimaryOrNull();
273    }
274
275    /**
276     * Get the user from the primary, or null if the user is anonymous
277     */
278    protected function getUserFromPrimaryOrNull(): ?User {
279        if ( !$this->userFromPrimary ) {
280            $this->userFromPrimary = $this->getUser()->getInstanceFromPrimary();
281        }
282
283        return $this->userFromPrimary;
284    }
285
286    /**
287     * Returns preferences form descriptor
288     * @return mixed[][]
289     */
290    protected function getPreferences() {
291        if ( !$this->preferences ) {
292            $this->preferences = $this->preferencesFactory->getFormDescriptor(
293                $this->getUser(),
294                $this->getContext()
295            );
296        }
297        return $this->preferences;
298    }
299
300    protected function getUserOptionsManager(): UserOptionsManager {
301        return $this->userOptionsManager;
302    }
303
304    protected function getPreferencesFactory(): PreferencesFactory {
305        return $this->preferencesFactory;
306    }
307
308    /**
309     * Reset preferences of the specified kinds
310     *
311     * @param string[] $kinds One or more types returned by PreferencesFactory::listResetKinds() or 'all'
312     */
313    abstract protected function resetPreferences( array $kinds );
314
315    /**
316     * Sets one user preference to be applied by commitChanges()
317     *
318     * @param string $preference
319     * @param mixed $value
320     */
321    abstract protected function setPreference( $preference, $value );
322
323    /**
324     * Applies changes to user preferences
325     */
326    abstract protected function commitChanges();
327
328    /** @inheritDoc */
329    public function mustBePosted() {
330        return true;
331    }
332
333    /** @inheritDoc */
334    public function isWriteMode() {
335        return true;
336    }
337
338    /** @inheritDoc */
339    public function getAllowedParams() {
340        $optionKinds = $this->preferencesFactory->listResetKinds();
341        $optionKinds[] = 'all';
342
343        return [
344            'reset' => false,
345            'resetkinds' => [
346                ParamValidator::PARAM_TYPE => $optionKinds,
347                ParamValidator::PARAM_DEFAULT => 'all',
348                ParamValidator::PARAM_ISMULTI => true
349            ],
350            'change' => [
351                ParamValidator::PARAM_ISMULTI => true,
352            ],
353            'optionname' => [
354                ParamValidator::PARAM_TYPE => 'string',
355            ],
356            'optionvalue' => [
357                ParamValidator::PARAM_TYPE => 'string',
358            ],
359        ];
360    }
361
362    /** @inheritDoc */
363    public function needsToken() {
364        return 'csrf';
365    }
366}
367
368/** @deprecated class alias since 1.43 */
369class_alias( ApiOptionsBase::class, 'ApiOptionsBase' );