Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 97
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SubmitControl
0.00% covered (danger)
0.00%
0 / 97
0.00% covered (danger)
0.00%
0 / 10
2162
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
 setInputParameters
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 submit
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 registerValidators
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 validateFieldInternal
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 getDefaultValidationError
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 getValidationResult
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
132
 getRequiredFields
n/a
0 / 0
n/a
0 / 0
0
 checkBasePermissions
n/a
0 / 0
n/a
0 / 0
0
 validateFields
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
72
 processAction
n/a
0 / 0
n/a
0 / 0
0
 failure
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 success
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\OAuth\Control;
4
5use ApiMessage;
6use ContextSource;
7use HTMLForm;
8use IContextSource;
9use LogicException;
10use MediaWiki\Status\Status;
11use Message;
12use MessageSpecifier;
13use MWException;
14use StatusValue;
15
16/**
17 * (c) Aaron Schulz 2013, GPL
18 *
19 * This program is free software; you can redistribute it and/or modify
20 * it under the terms of the GNU General Public License as published by
21 * the Free Software Foundation; either version 2 of the License, or
22 * (at your option) any later version.
23 *
24 * This program is distributed in the hope that it will be useful,
25 * but WITHOUT ANY WARRANTY; without even the implied warranty of
26 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
27 * GNU General Public License for more details.
28 *
29 * You should have received a copy of the GNU General Public License along
30 * with this program; if not, write to the Free Software Foundation, Inc.,
31 * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
32 * http://www.gnu.org/copyleft/gpl.html
33 */
34
35/**
36 * Handle the logic of submitting a client request
37 */
38abstract class SubmitControl extends ContextSource {
39    /** @var array (field name => value) */
40    protected $vals;
41
42    /**
43     * @param IContextSource $context
44     * @param array $params
45     */
46    public function __construct( IContextSource $context, array $params ) {
47        $this->setContext( $context );
48        $this->vals = $params;
49    }
50
51    /**
52     * @param array $params
53     */
54    public function setInputParameters( array $params ) {
55        $this->vals = $params;
56    }
57
58    /**
59     * Attempt to validate and submit this data
60     *
61     * This will check basic permissions, validate the action and parameters
62     * and route the submission handling to the internal subclass function.
63     *
64     * @throws MWException
65     * @return Status
66     */
67    public function submit() {
68        $status = $this->checkBasePermissions();
69        if ( !$status->isOK() ) {
70            return $status;
71        }
72
73        $action = $this->vals['action'];
74        $required = $this->getRequiredFields();
75        if ( !isset( $required[$action] ) ) {
76            // @TODO: check for field-specific message first
77            return $this->failure( 'invalid_field_action', 'mwoauth-invalid-field', 'action' );
78        }
79
80        $status = $this->validateFields( $required[$action] );
81        if ( !$status->isOK() ) {
82            return $status;
83        }
84
85        $status = $this->processAction( $action );
86        if ( $status instanceof Status ) {
87            return $status;
88        } else {
89            throw new MWException( "Submission action '$action' not handled." );
90        }
91    }
92
93    /**
94     * Add the validators from getRequiredFields() to the given HTMLForm descriptor.
95     * Existing validators are not overridden.
96     *
97     * It also adds a checkbox to override warnings when necessary.
98     *
99     * @param array[] $descriptors
100     * @return array[]
101     */
102    public function registerValidators( array $descriptors ) {
103        foreach ( $descriptors as $field => &$description ) {
104            if ( array_key_exists( 'validation-callback', $description ) ) {
105                // already set to something
106                continue;
107            }
108            $description['validation-callback'] =
109                function ( $value, $allValues, $form ) use ( $field ) {
110                    return $this->validateFieldInternal( $field, $value, $allValues, $form );
111                };
112        }
113        $descriptors['ignorewarnings'] = [
114            'type' => 'check',
115            'label-message' => 'mwoauth-ignorewarnings',
116            'cssclass' => 'mw-oauth-form-ignorewarnings-hidden',
117        ];
118        return $descriptors;
119    }
120
121    /**
122     * Do some basic checks and call the validator provided by getRequiredFields().
123     * This method should not be called outside SubmitControl.
124     *
125     * @param string $field
126     * @param mixed $value
127     * @param array $allValues
128     * @param HTMLForm $form
129     * @throws MWException
130     * @return true|string
131     */
132    public function validateFieldInternal( string $field, $value, array $allValues, HTMLForm $form ) {
133        if ( !isset( $allValues['action'] ) && isset( $this->vals['action'] ) ) {
134            // The action may be derived, especially for multi-button forms.
135            // Such an HTMLForm will not have an action key set in $allValues.
136            $allValues['action'] = $this->vals['action'];
137        }
138        if ( !isset( $allValues['action'] ) ) {
139            throw new MWException( "No form action defined; cannot validate fields." );
140        }
141        $validators = $this->getRequiredFields();
142        if ( !isset( $validators[$allValues['action']][$field] ) ) {
143            // nothing to check
144            return true;
145        }
146        $validator = $validators[$allValues['action']][$field];
147        $validationResult = $this->getValidationResult( $validator, $value, $allValues, $form );
148        if ( $validationResult === false ) {
149            return $this->getDefaultValidationError( $field, $value, $form )->text();
150        } elseif ( $validationResult instanceof ApiMessage ) {
151            return $validationResult->parse();
152        }
153        return true;
154    }
155
156    /**
157     * Generate an error message for a field. Used when the validator returns false.
158     *
159     * @param string $field
160     * @param mixed $value
161     * @param HTMLForm|null $form
162     * @return Message Error message (to be rendered via text()).
163     */
164    private function getDefaultValidationError( string $field, $value, HTMLForm $form = null ): Message {
165        $errorMessage = $this->msg( 'mwoauth-invalid-field-' . $field );
166        if ( !$errorMessage->isDisabled() ) {
167            return $errorMessage;
168        }
169
170        $generic = '';
171        if ( $form && $form->getField( $field )->canDisplayErrors() ) {
172            // error can be shown right next to the field so no need to mention the field name
173            $generic = '-generic';
174        }
175
176        $problem = 'invalid';
177        if ( $value === '' && !$generic ) {
178            $problem = 'missing';
179        }
180
181        // messages: mwoauth-missing-field, mwoauth-invalid-field, mwoauth-invalid-field-generic
182        return $this->msg( "mwoauth-$problem-field$generic", $field );
183    }
184
185    /**
186     * @param mixed $validator One of the callbacks registered via registerValidator.
187     * @param mixed $value The value of the field being validated.
188     * @param array $allValues All field values, keyed by field name.
189     * @param HTMLForm|null $form
190     * @return bool|ApiMessage
191     * @phan-param string|callable(mixed,array):(bool|StatusValue) $validator
192     */
193    private function getValidationResult( $validator, $value, array $allValues, HTMLForm $form = null ) {
194        if ( is_string( $validator ) ) {
195            return preg_match( $validator, $value ?? '' );
196        }
197        $result = $validator( $value, $allValues );
198        if ( $result instanceof StatusValue ) {
199            if ( $result->isGood() ) {
200                return true;
201            } elseif ( count( $result->getErrors() ) !== 1 ) {
202                throw new LogicException( 'Validator return status has too many errors: '
203                    . $result );
204            }
205            [ $errors, $warnings ] = $result->splitByErrorType();
206            if ( $errors->isOK() ) {
207                // $result is a warning -  if the user checked "ignore warnings", ignore;
208                // otherwise show the checkbox
209                if ( $form ) {
210                    // This is a horrible hack. There doesn't seem to be a way to modify a form's
211                    // CSS classes or other display properties between validation and rendering.
212                    $form->setId( 'oauth-form-with-warnings' );
213                }
214
215                if ( $allValues['ignorewarnings'] ?? false ) {
216                    return true;
217                }
218            }
219            $result = $result->getErrors()[0]['message'];
220        }
221        if ( is_bool( $result ) || $result instanceof ApiMessage ) {
222            return $result;
223        }
224
225        $type = gettype( $result );
226        if ( $type === 'object' ) {
227            $type = get_class( $result );
228        }
229        throw new LogicException( 'Invalid validator return type: ' . $type );
230    }
231
232    /**
233     * Get the field names and their validation methods. Fields can be omitted. Fields that are
234     * not
235     *
236     * A validation method is either a regex string or a callable.
237     * Callables take (field value, field/value map) as params and must return a boolean or a
238     * StatusValue with a single ApiMessage in it. If that is a warning, the user will be allowed
239     * to override it. A StatusValue with an error of boolean false will prevent submission.
240     *
241     * When false is returned, the error message mwoauth-invalid-field-<fieldname> will be shown
242     * if it exists. Otherwise, a generic message will be used (see getDefaultValidationError()).
243     *
244     * @return array (action => (field name => validation regex or function))
245     * @phan-return array<string,array<string,string|callable(mixed):(bool|StatusValue)|callable(mixed,array):(bool|StatusValue)>>
246     */
247    abstract protected function getRequiredFields();
248
249    /**
250     * Check action-independent permissions against the user for this submission
251     *
252     * @return Status
253     */
254    abstract protected function checkBasePermissions();
255
256    /**
257     * Check that the action is valid and that the required fields are valid
258     *
259     * @param array $required (field => regex or callback)
260     * @phan-param array<string,string|callable(mixed,array):bool|StatusValue> $required
261     * @return Status
262     */
263    protected function validateFields( array $required ) {
264        foreach ( $required as $field => $validator ) {
265            if ( !isset( $this->vals[$field] ) ) {
266                return $this->failure( "missing_field_$field", 'mwoauth-missing-field', $field );
267            } elseif ( !is_scalar( $this->vals[$field] )
268                && !in_array( $field, [ 'restrictions', 'oauth2GrantTypes' ], true )
269            ) {
270                return $this->failure( "invalid_field_$field", 'mwoauth-invalid-field', $field );
271            }
272            if ( is_string( $this->vals[$field] ) ) {
273                $this->vals[$field] = trim( $this->vals[$field] );
274            }
275            $validationResult = $this->getValidationResult( $validator, $this->vals[$field], $this->vals );
276            if ( $validationResult === false ) {
277                $message = $this->getDefaultValidationError( $field, $this->vals[$field] );
278                return $this->failure( "invalid_field_$field", $message );
279            } elseif ( $validationResult instanceof ApiMessage ) {
280                return $this->failure( $validationResult->getApiCode(), $validationResult, $field );
281            }
282        }
283        return $this->success();
284    }
285
286    /**
287     * Attempt to validate and submit this data for the given action
288     *
289     * @param string $action
290     * @return Status
291     */
292    abstract protected function processAction( $action );
293
294    /**
295     * @param string $error API error key
296     * @param string|MessageSpecifier $msg Message
297     * @param mixed ...$params Additional arguments used as message parameters
298     * @return Status
299     */
300    protected function failure( $error, $msg, ...$params ) {
301        // Use the same logic as wfMessage
302        if ( isset( $params[0] ) && is_array( $params[0] ) ) {
303            $params = $params[0];
304        }
305        $status = Status::newFatal( $this->msg( $msg, $params ) );
306        $status->value = [ 'error' => $error, 'result' => null ];
307        return $status;
308    }
309
310    /**
311     * @param mixed|null $value
312     * @return Status
313     */
314    protected function success( $value = null ) {
315        return Status::newGood( [ 'error' => null, 'result' => $value ] );
316    }
317}