Completed
Push — master ( c17796...052b15 )
by Damian
01:29
created

FormRequestHandler::httpSubmission()   F

Complexity

Conditions 26
Paths 3365

Size

Total Lines 157
Code Lines 87

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 26
eloc 87
nc 3365
nop 1
dl 0
loc 157
rs 2
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace SilverStripe\Forms;
4
5
use BadMethodCallException;
6
use SilverStripe\Control\Controller;
7
use SilverStripe\Control\Director;
8
use SilverStripe\Control\HTTPRequest;
9
use SilverStripe\Control\HTTPResponse;
10
use SilverStripe\Control\HTTPResponse_Exception;
11
use SilverStripe\Control\RequestHandler;
12
use SilverStripe\Core\ClassInfo;
13
use SilverStripe\Core\Convert;
14
use SilverStripe\ORM\ValidationResult;
15
use SilverStripe\ORM\SS_List;
16
use SilverStripe\ORM\ValidationException;
17
18
class FormRequestHandler extends RequestHandler
19
{
20
    /**
21
     * @var callable|null
22
     */
23
    protected $buttonClickedFunc;
24
25
    /**
26
     * @config
27
     * @var array
28
     */
29
    private static $allowed_actions = array(
0 ignored issues
show
introduced by
The private property $allowed_actions is not used, and could be removed.
Loading history...
30
        'handleField',
31
        'httpSubmission',
32
        'forTemplate',
33
    );
34
35
    /**
36
     * @config
37
     * @var array
38
     */
39
    private static $url_handlers = array(
0 ignored issues
show
introduced by
The private property $url_handlers is not used, and could be removed.
Loading history...
40
        'field/$FieldName!' => 'handleField',
41
        'POST ' => 'httpSubmission',
42
        'GET ' => 'httpSubmission',
43
        'HEAD ' => 'httpSubmission',
44
    );
45
46
    /**
47
     * Form model being handled
48
     *
49
     * @var Form
50
     */
51
    protected $form = null;
52
53
    /**
54
     * Build a new request handler for a given Form model
55
     *
56
     * @param Form $form
57
     */
58
    public function __construct(Form $form)
59
    {
60
        $this->form = $form;
61
        parent::__construct();
62
63
        // Inherit parent controller request
64
        $parent = $this->form->getController();
65
        if ($parent) {
66
            $this->setRequest($parent->getRequest());
67
        }
68
    }
69
70
71
    /**
72
     * Get link for this form
73
     *
74
     * @param string $action
75
     * @return string
76
     */
77
    public function Link($action = null)
78
    {
79
        // Forms without parent controller have no link;
80
        // E.g. Submission handled via graphql
81
        $controller = $this->form->getController();
82
        if (empty($controller)) {
83
            return null;
84
        }
85
86
        // Respect FormObjectLink() method
87
        if ($controller->hasMethod("FormObjectLink")) {
88
            return Controller::join_links(
89
                $controller->FormObjectLink($this->form->getName()),
0 ignored issues
show
Bug introduced by
The method FormObjectLink() does not exist on SilverStripe\Control\RequestHandler. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

89
                $controller->/** @scrutinizer ignore-call */ 
90
                             FormObjectLink($this->form->getName()),
Loading history...
90
                $action,
91
                '/'
92
            );
93
        }
94
95
        // Default form link
96
        return Controller::join_links($controller->Link(), $this->form->getName(), $action, '/');
97
    }
98
99
    /**
100
     * Handle a form submission.  GET and POST requests behave identically.
101
     * Populates the form with {@link loadDataFrom()}, calls {@link validate()},
102
     * and only triggers the requested form action/method
103
     * if the form is valid.
104
     *
105
     * @param HTTPRequest $request
106
     * @return HTTPResponse
107
     * @throws HTTPResponse_Exception
108
     */
109
    public function httpSubmission($request)
110
    {
111
        // Strict method check
112
        if ($this->form->getStrictFormMethodCheck()) {
113
            // Throws an error if the method is bad...
114
            $allowedMethod = $this->form->FormMethod();
115
            if ($allowedMethod !== $request->httpMethod()) {
116
                $response = Controller::curr()->getResponse();
117
                $response->addHeader('Allow', $allowedMethod);
118
                $this->httpError(405, _t(
119
                    "SilverStripe\\Forms\\Form.BAD_METHOD",
120
                    "This form requires a {method} submission",
121
                    ['method' => $allowedMethod]
122
                ));
123
            }
124
125
            // ...and only uses the variables corresponding to that method type
126
            $vars = $allowedMethod === 'GET'
127
                ? $request->getVars()
128
                : $request->postVars();
129
        } else {
130
            $vars = $request->requestVars();
131
        }
132
133
        // Ensure we only process saveable fields (non structural, readonly, or disabled)
134
        $allowedFields = array_keys($this->form->Fields()->saveableFields());
135
136
        // Populate the form
137
        $this->form->loadDataFrom($vars, true, $allowedFields);
0 ignored issues
show
Bug introduced by
true of type true is incompatible with the type integer expected by parameter $mergeStrategy of SilverStripe\Forms\Form::loadDataFrom(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

137
        $this->form->loadDataFrom($vars, /** @scrutinizer ignore-type */ true, $allowedFields);
Loading history...
138
139
        // Protection against CSRF attacks
140
        // @todo Move this to SecurityTokenField::validate()
141
        $token = $this->form->getSecurityToken();
142
        if (! $token->checkRequest($request)) {
143
            $securityID = $token->getName();
144
            if (empty($vars[$securityID])) {
145
                $this->httpError(400, _t(
146
                    "SilverStripe\\Forms\\Form.CSRF_FAILED_MESSAGE",
147
                    "There seems to have been a technical problem. Please click the back button, ".
148
                    "refresh your browser, and try again."
149
                ));
150
            } else {
151
                // Clear invalid token on refresh
152
                $this->form->clearFormState();
153
                $data = $this->form->getData();
154
                unset($data[$securityID]);
155
                $this->form
156
                    ->setSessionData($data)
157
                    ->sessionError(_t(
158
                        "SilverStripe\\Forms\\Form.CSRF_EXPIRED_MESSAGE",
159
                        "Your session has expired. Please re-submit the form."
160
                    ));
161
162
                // Return the user
163
                return $this->redirectBack();
164
            }
165
        }
166
167
        // Determine the action button clicked
168
        $funcName = null;
169
        foreach ($vars as $paramName => $paramVal) {
170
            if (substr($paramName, 0, 7) == 'action_') {
171
                // Break off querystring arguments included in the action
172
                if (strpos($paramName, '?') !== false) {
173
                    list($paramName, $paramVars) = explode('?', $paramName, 2);
174
                    $newRequestParams = array();
175
                    parse_str($paramVars, $newRequestParams);
176
                    $vars = array_merge((array)$vars, (array)$newRequestParams);
177
                }
178
179
                // Cleanup action_, _x and _y from image fields
180
                $funcName = preg_replace(array('/^action_/','/_x$|_y$/'), '', $paramName);
181
                break;
182
            }
183
        }
184
185
        // If the action wasn't set, choose the default on the form.
186
        if (!isset($funcName) && $defaultAction = $this->form->defaultAction()) {
187
            $funcName = $defaultAction->actionName();
188
        }
189
190
        if (isset($funcName)) {
191
            $this->setButtonClicked($funcName);
192
        }
193
194
        // Permission checks (first on controller, then falling back to request handler)
195
        $controller = $this->form->getController();
196
        if (// Ensure that the action is actually a button or method on the form,
197
            // and not just a method on the controller.
198
            $controller
199
            && $controller->hasMethod($funcName)
200
            && !$controller->checkAccessAction($funcName)
201
            // If a button exists, allow it on the controller
202
            // buttonClicked() validates that the action set above is valid
203
            && !$this->buttonClicked()
204
        ) {
205
            return $this->httpError(
206
                403,
207
                sprintf('Action "%s" not allowed on controller (Class: %s)', $funcName, get_class($controller))
208
            );
209
        } elseif (// No checks for button existence or $allowed_actions is performed -
210
            // all form methods are callable (e.g. the legacy "callfieldmethod()")
211
            $this->hasMethod($funcName)
212
            && !$this->checkAccessAction($funcName)
213
        ) {
214
            return $this->httpError(
215
                403,
216
                sprintf('Action "%s" not allowed on form request handler (Class: "%s")', $funcName, static::class)
217
            );
218
        }
219
220
        // Action handlers may throw ValidationExceptions.
221
        try {
222
            // Or we can use the Valiator attached to the form
223
            $result = $this->form->validationResult();
224
            if (!$result->isValid()) {
225
                return $this->getValidationErrorResponse($result);
226
            }
227
228
            // First, try a handler method on the controller (has been checked for allowed_actions above already)
229
            $controller = $this->form->getController();
230
            if ($controller && $controller->hasMethod($funcName)) {
231
                return $controller->$funcName($vars, $this->form, $request, $this);
232
            }
233
234
            // Otherwise, try a handler method on the form request handler.
235
            if ($this->hasMethod($funcName)) {
236
                return $this->$funcName($vars, $this->form, $request, $this);
237
            }
238
239
            // Otherwise, try a handler method on the form itself
240
            if ($this->form->hasMethod($funcName)) {
241
                return $this->form->$funcName($vars, $this->form, $request, $this);
242
            }
243
244
            // Check for inline actions
245
            $field = $this->checkFieldsForAction($this->form->Fields(), $funcName);
246
            if ($field) {
247
                return $field->$funcName($vars, $this->form, $request, $this);
248
            }
249
        } catch (ValidationException $e) {
250
            // The ValdiationResult contains all the relevant metadata
251
            $result = $e->getResult();
252
            $this->form->loadMessagesFrom($result);
253
            return $this->getValidationErrorResponse($result);
254
        }
255
256
        // Determine if legacy form->allowed_actions is set
257
        $legacyActions = $this->form->config()->get('allowed_actions');
258
        if ($legacyActions) {
259
            throw new BadMethodCallException(
260
                "allowed_actions are not valid on Form class " . get_class($this->form) .
261
                ". Implement these in subclasses of " . static::class . " instead"
262
            );
263
        }
264
265
        return $this->httpError(404);
266
    }
267
268
    /**
269
     * @param string $action
270
     * @return bool
271
     */
272
    public function checkAccessAction($action)
273
    {
274
        if (parent::checkAccessAction($action)) {
275
            return true;
276
        }
277
278
        $actions = $this->getAllActions();
279
        foreach ($actions as $formAction) {
280
            if ($formAction->actionName() === $action) {
281
                return true;
282
            }
283
        }
284
285
            // Always allow actions on fields
286
        $field = $this->checkFieldsForAction($this->form->Fields(), $action);
287
        if ($field && $field->checkAccessAction($action)) {
288
            return true;
289
        }
290
291
        return false;
292
    }
293
294
295
296
    /**
297
     * Returns the appropriate response up the controller chain
298
     * if {@link validate()} fails (which is checked prior to executing any form actions).
299
     * By default, returns different views for ajax/non-ajax request, and
300
     * handles 'application/json' requests with a JSON object containing the error messages.
301
     * Behaviour can be influenced by setting {@link $redirectToFormOnValidationError},
302
     * and can be overruled by setting {@link $validationResponseCallback}.
303
     *
304
     * @param ValidationResult $result
305
     * @return HTTPResponse
306
     */
307
    protected function getValidationErrorResponse(ValidationResult $result)
308
    {
309
        // Check for custom handling mechanism
310
        $callback = $this->form->getValidationResponseCallback();
311
        if ($callback && $callbackResponse = call_user_func($callback, $result)) {
312
            return $callbackResponse;
313
        }
314
315
        // Check if handling via ajax
316
        if ($this->getRequest()->isAjax()) {
317
            return $this->getAjaxErrorResponse($result);
318
        }
319
320
        // Prior to redirection, persist this result in session to re-display on redirect
321
        $this->form->setSessionValidationResult($result);
322
        $this->form->setSessionData($this->form->getData());
323
324
        // Determine redirection method
325
        if ($this->form->getRedirectToFormOnValidationError()) {
326
            return $this->redirectBackToForm();
327
        }
328
        return $this->redirectBack();
329
    }
330
331
    /**
332
     * Redirect back to this form with an added #anchor link
333
     *
334
     * @return HTTPResponse
335
     */
336
    public function redirectBackToForm()
337
    {
338
        $pageURL = $this->getReturnReferer();
339
        if (!$pageURL) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $pageURL of type null|string is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
340
            return $this->redirectBack();
341
        }
342
343
        // Add backURL and anchor
344
        $pageURL = Controller::join_links(
345
            $this->addBackURLParam($pageURL),
346
            '#' . $this->form->FormName()
347
        );
348
349
        // Redirect
350
        return $this->redirect($pageURL);
351
    }
352
353
    /**
354
     * Helper to add ?BackURL= to any link
355
     *
356
     * @param string $link
357
     * @return string
358
     */
359
    protected function addBackURLParam($link)
360
    {
361
        $backURL = $this->getBackURL();
362
        if ($backURL) {
363
            return Controller::join_links($link, '?BackURL=' . urlencode($backURL));
364
        }
365
        return $link;
366
    }
367
368
    /**
369
     * Build HTTP error response for ajax requests
370
     *
371
     * @internal called from {@see Form::getValidationErrorResponse}
372
     * @param ValidationResult $result
373
     * @return HTTPResponse
374
     */
375
    protected function getAjaxErrorResponse(ValidationResult $result)
376
    {
377
        // Ajax form submissions accept json encoded errors by default
378
        $acceptType = $this->getRequest()->getHeader('Accept');
379
        if (strpos($acceptType, 'application/json') !== false) {
380
            // Send validation errors back as JSON with a flag at the start
381
            $response = new HTTPResponse(Convert::array2json($result->getMessages()));
382
            $response->addHeader('Content-Type', 'application/json');
383
            return $response;
384
        }
385
386
        // Send the newly rendered form tag as HTML
387
        $this->form->loadMessagesFrom($result);
388
        $response = new HTTPResponse($this->form->forTemplate());
389
        $response->addHeader('Content-Type', 'text/html');
390
        return $response;
391
    }
392
393
    /**
394
     * Fields can have action to, let's check if anyone of the responds to $funcname them
395
     *
396
     * @param SS_List|array $fields
397
     * @param callable $funcName
398
     * @return FormField
399
     */
400
    protected function checkFieldsForAction($fields, $funcName)
401
    {
402
        foreach ($fields as $field) {
403
            /** @skipUpgrade */
404
            if (ClassInfo::hasMethod($field, 'FieldList')) {
405
                if ($field = $this->checkFieldsForAction($field->FieldList(), $funcName)) {
406
                    return $field;
407
                }
408
            } elseif ($field->hasMethod($funcName) && $field->checkAccessAction($funcName)) {
409
                return $field;
410
            }
411
        }
412
        return null;
413
    }
414
415
    /**
416
     * Handle a field request.
417
     * Uses {@link Form->dataFieldByName()} to find a matching field,
418
     * and falls back to {@link FieldList->fieldByName()} to look
419
     * for tabs instead. This means that if you have a tab and a
420
     * formfield with the same name, this method gives priority
421
     * to the formfield.
422
     *
423
     * @param HTTPRequest $request
424
     * @return FormField
425
     */
426
    public function handleField($request)
427
    {
428
        $field = $this->form->Fields()->dataFieldByName($request->param('FieldName'));
429
430
        if ($field) {
431
            return $field;
432
        } else {
433
            // falling back to fieldByName, e.g. for getting tabs
434
            return $this->form->Fields()->fieldByName($request->param('FieldName'));
435
        }
436
    }
437
438
    /**
439
     * Sets the button that was clicked.  This should only be called by the Controller.
440
     *
441
     * @param callable $funcName The name of the action method that will be called.
442
     * @return $this
443
     */
444
    public function setButtonClicked($funcName)
445
    {
446
        $this->buttonClickedFunc = $funcName;
447
        return $this;
448
    }
449
450
    /**
451
     * Get instance of button which was clicked for this request
452
     *
453
     * @return FormAction
454
     */
455
    public function buttonClicked()
456
    {
457
        $actions = $this->getAllActions();
458
        foreach ($actions as $action) {
459
            if ($this->buttonClickedFunc === $action->actionName()) {
460
                return $action;
461
            }
462
        }
463
        return null;
464
    }
465
466
    /**
467
     * Get a list of all actions, including those in the main "fields" FieldList
468
     *
469
     * @return array
470
     */
471
    protected function getAllActions()
472
    {
473
        $fields = $this->form->Fields()->dataFields();
474
        $actions = $this->form->Actions()->dataFields();
475
476
        $fieldsAndActions = array_merge($fields, $actions);
477
        $actions = array_filter($fieldsAndActions, function ($fieldOrAction) {
478
            return $fieldOrAction instanceof FormAction;
479
        });
480
481
        return $actions;
482
    }
483
484
    /**
485
     * Processing that occurs before a form is executed.
486
     *
487
     * This includes form validation, if it fails, we throw a ValidationException
488
     *
489
     * This includes form validation, if it fails, we redirect back
490
     * to the form with appropriate error messages.
491
     * Always return true if the current form action is exempt from validation
492
     *
493
     * Triggered through {@link httpSubmission()}.
494
     *
495
     *
496
     * Note that CSRF protection takes place in {@link httpSubmission()},
497
     * if it fails the form data will never reach this method.
498
     *
499
     * @return ValidationResult
500
     */
501
    public function validationResult()
502
    {
503
        // Check if button is exempt, or if there is no validator
504
        $action = $this->buttonClicked();
505
        $validator = $this->form->getValidator();
506
        if (!$validator || $this->form->actionIsValidationExempt($action)) {
507
            return ValidationResult::create();
508
        }
509
510
        // Invoke validator
511
        $result = $validator->validate();
512
        $this->form->loadMessagesFrom($result);
513
        return $result;
514
    }
515
516
    public function forTemplate()
517
    {
518
        return $this->form->forTemplate();
519
    }
520
}
521