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