Passed
Pull Request — 4.5 (#9396)
by Nic
16:57 queued 07:30
created

FormRequestHandler::invokeFormHandler()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
nc 1
nop 4
dl 0
loc 7
rs 10
c 0
b 0
f 0
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) {
0 ignored issues
show
introduced by
$parent is of type SilverStripe\Control\RequestHandler, thus it always evaluated to true.
Loading history...
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
            $base = $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

88
            /** @scrutinizer ignore-call */ 
89
            $base = $controller->FormObjectLink($this->form->getName());
Loading history...
89
        } else {
90
            $base = Controller::join_links($controller->Link(), $this->form->getName());
91
        }
92
93
        // Join with action and decorate
94
        $link = Controller::join_links($base, $action, '/');
95
        $this->extend('updateLink', $link, $action);
96
        return $link;
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, " . "refresh your browser, and try again."
148
                ));
149
            } else {
150
                // Clear invalid token on refresh
151
                $this->form->clearFormState();
152
                $data = $this->form->getData();
153
                unset($data[$securityID]);
154
                $this->form
155
                    ->setSessionData($data)
156
                    ->sessionError(_t(
157
                        "SilverStripe\\Forms\\Form.CSRF_EXPIRED_MESSAGE",
158
                        "Your session has expired. Please re-submit the form."
159
                    ));
160
                // Return the user
161
                return $this->redirectBack();
162
            }
163
        }
164
165
        // Determine the action button clicked
166
        $funcName = null;
167
        foreach ($vars as $paramName => $paramVal) {
168
            if (substr($paramName, 0, 7) == 'action_') {
169
                // Break off querystring arguments included in the action
170
                if (strpos($paramName, '?') !== false) {
171
                    list($paramName, $paramVars) = explode('?', $paramName, 2);
172
                    $newRequestParams = array();
173
                    parse_str($paramVars, $newRequestParams);
174
                    $vars = array_merge((array)$vars, (array)$newRequestParams);
175
                }
176
177
                // Cleanup action_, _x and _y from image fields
178
                $funcName = preg_replace(array('/^action_/','/_x$|_y$/'), '', $paramName);
179
                break;
180
            }
181
        }
182
183
        // If the action wasn't set, choose the default on the form.
184
        if (!isset($funcName) && $defaultAction = $this->form->defaultAction()) {
185
            $funcName = $defaultAction->actionName();
186
        }
187
188
        if (isset($funcName)) {
189
            $this->setButtonClicked($funcName);
190
        }
191
192
        // Permission checks (first on controller, then falling back to request handler)
193
        $controller = $this->form->getController();
194
        if (// Ensure that the action is actually a button or method on the form,
195
            // and not just a method on the controller.
196
            $controller
197
            && $controller->hasMethod($funcName)
198
            && !$controller->checkAccessAction($funcName)
199
            // If a button exists, allow it on the controller
200
            // buttonClicked() validates that the action set above is valid
201
            && !$this->buttonClicked()
202
        ) {
203
            return $this->httpError(
204
                403,
205
                sprintf('Action "%s" not allowed on controller (Class: %s)', $funcName, get_class($controller))
206
            );
207
        } elseif (// No checks for button existence or $allowed_actions is performed -
208
            // all form methods are callable (e.g. the legacy "callfieldmethod()")
209
            $this->hasMethod($funcName)
210
            && !$this->checkAccessAction($funcName)
211
        ) {
212
            return $this->httpError(
213
                403,
214
                sprintf('Action "%s" not allowed on form request handler (Class: "%s")', $funcName, static::class)
215
            );
216
        }
217
218
        // Action handlers may throw ValidationExceptions.
219
        try {
220
            // Or we can use the Valiator attached to the form
221
            $result = $this->form->validationResult();
222
            if (!$result->isValid()) {
223
                return $this->getValidationErrorResponse($result);
224
            }
225
226
            // First, try a handler method on the controller (has been checked for allowed_actions above already)
227
            $controller = $this->form->getController();
228
            $args = [$funcName, $request, $vars];
229
            if ($controller && $controller->hasMethod($funcName)) {
230
                $controller->setRequest($request);
231
                return $this->invokeFormHandler($controller, ...$args);
232
            }
233
234
            // Otherwise, try a handler method on the form request handler.
235
            if ($this->hasMethod($funcName)) {
236
                return $this->invokeFormHandler($this, ...$args);
237
            }
238
239
            // Otherwise, try a handler method on the form itself
240
            if ($this->form->hasMethod($funcName)) {
241
                return $this->invokeFormHandler($this->form, ...$args);
242
            }
243
244
            // Check for inline actions
245
            $field = $this->checkFieldsForAction($this->form->Fields(), $funcName);
246
            if ($field) {
247
                return $this->invokeFormHandler($field, ...$args);
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) . ". Implement these in subclasses of " . static::class . " instead"
261
            );
262
        }
263
264
        return $this->httpError(404, "Could not find a suitable form-action callback function");
265
    }
266
267
    /**
268
     * @param string $action
269
     * @return bool
270
     */
271
    public function checkAccessAction($action)
272
    {
273
        if (parent::checkAccessAction($action)) {
274
            return true;
275
        }
276
277
        $actions = $this->getAllActions();
278
        foreach ($actions as $formAction) {
279
            if ($formAction->actionName() === $action) {
280
                return true;
281
            }
282
        }
283
284
            // Always allow actions on fields
285
        $field = $this->checkFieldsForAction($this->form->Fields(), $action);
286
        if ($field && $field->checkAccessAction($action)) {
287
            return true;
288
        }
289
290
        return false;
291
    }
292
293
294
295
    /**
296
     * Returns the appropriate response up the controller chain
297
     * if {@link validate()} fails (which is checked prior to executing any form actions).
298
     * By default, returns different views for ajax/non-ajax request, and
299
     * handles 'application/json' requests with a JSON object containing the error messages.
300
     * Behaviour can be influenced by setting {@link $redirectToFormOnValidationError},
301
     * and can be overruled by setting {@link $validationResponseCallback}.
302
     *
303
     * @param ValidationResult $result
304
     * @return HTTPResponse
305
     */
306
    protected function getValidationErrorResponse(ValidationResult $result)
307
    {
308
        // Check for custom handling mechanism
309
        $callback = $this->form->getValidationResponseCallback();
310
        if ($callback && $callbackResponse = call_user_func($callback, $result)) {
311
            return $callbackResponse;
312
        }
313
314
        // Check if handling via ajax
315
        if ($this->getRequest()->isAjax()) {
316
            return $this->getAjaxErrorResponse($result);
317
        }
318
319
        // Prior to redirection, persist this result in session to re-display on redirect
320
        $this->form->setSessionValidationResult($result);
321
        $this->form->setSessionData($this->form->getData());
322
323
        // Determine redirection method
324
        if ($this->form->getRedirectToFormOnValidationError()) {
325
            return $this->redirectBackToForm();
326
        }
327
        return $this->redirectBack();
328
    }
329
330
    /**
331
     * Redirect back to this form with an added #anchor link
332
     *
333
     * @return HTTPResponse
334
     */
335
    public function redirectBackToForm()
336
    {
337
        $pageURL = $this->getReturnReferer();
338
        if (!$pageURL) {
339
            return $this->redirectBack();
340
        }
341
342
        // Add backURL and anchor
343
        $pageURL = Controller::join_links(
344
            $this->addBackURLParam($pageURL),
345
            '#' . $this->form->FormName()
346
        );
347
348
        // Redirect
349
        return $this->redirect($pageURL);
350
    }
351
352
    /**
353
     * Helper to add ?BackURL= to any link
354
     *
355
     * @param string $link
356
     * @return string
357
     */
358
    protected function addBackURLParam($link)
359
    {
360
        $backURL = $this->getBackURL();
361
        if ($backURL) {
362
            return Controller::join_links($link, '?BackURL=' . urlencode($backURL));
363
        }
364
        return $link;
365
    }
366
367
    /**
368
     * Build HTTP error response for ajax requests
369
     *
370
     * @internal called from {@see Form::getValidationErrorResponse}
371
     * @param ValidationResult $result
372
     * @return HTTPResponse
373
     */
374
    protected function getAjaxErrorResponse(ValidationResult $result)
375
    {
376
        // Ajax form submissions accept json encoded errors by default
377
        $acceptType = $this->getRequest()->getHeader('Accept');
378
        if (strpos($acceptType, 'application/json') !== false) {
379
            // Send validation errors back as JSON with a flag at the start
380
            $response = new HTTPResponse(json_encode($result->getMessages()));
381
            $response->addHeader('Content-Type', 'application/json');
382
            return $response;
383
        }
384
385
        // Send the newly rendered form tag as HTML
386
        $this->form->loadMessagesFrom($result);
387
        $response = new HTTPResponse($this->form->forTemplate());
388
        $response->addHeader('Content-Type', 'text/html');
389
        return $response;
390
    }
391
392
    /**
393
     * Fields can have action to, let's check if anyone of the responds to $funcname them
394
     *
395
     * @param SS_List|array $fields
396
     * @param callable $funcName
397
     * @return FormField
398
     */
399
    protected function checkFieldsForAction($fields, $funcName)
400
    {
401
        foreach ($fields as $field) {
402
            /** @skipUpgrade */
403
            if (ClassInfo::hasMethod($field, 'FieldList')) {
404
                if ($field = $this->checkFieldsForAction($field->FieldList(), $funcName)) {
405
                    return $field;
406
                }
407
            } elseif ($field->hasMethod($funcName) && $field->checkAccessAction($funcName)) {
408
                return $field;
409
            }
410
        }
411
        return null;
412
    }
413
414
    /**
415
     * Handle a field request.
416
     * Uses {@link Form->dataFieldByName()} to find a matching field,
417
     * and falls back to {@link FieldList->fieldByName()} to look
418
     * for tabs instead. This means that if you have a tab and a
419
     * formfield with the same name, this method gives priority
420
     * to the formfield.
421
     *
422
     * @param HTTPRequest $request
423
     * @return FormField
424
     */
425
    public function handleField($request)
426
    {
427
        $field = $this->form->Fields()->dataFieldByName($request->param('FieldName'));
428
429
        if ($field) {
0 ignored issues
show
introduced by
$field is of type SilverStripe\Forms\FormField, thus it always evaluated to true.
Loading history...
430
            return $field;
431
        } else {
432
            // falling back to fieldByName, e.g. for getting tabs
433
            return $this->form->Fields()->fieldByName($request->param('FieldName'));
434
        }
435
    }
436
437
    /**
438
     * Sets the button that was clicked.  This should only be called by the Controller.
439
     *
440
     * @param callable $funcName The name of the action method that will be called.
441
     * @return $this
442
     */
443
    public function setButtonClicked($funcName)
444
    {
445
        $this->buttonClickedFunc = $funcName;
446
        return $this;
447
    }
448
449
    /**
450
     * Get instance of button which was clicked for this request
451
     *
452
     * @return FormAction
453
     */
454
    public function buttonClicked()
455
    {
456
        $actions = $this->getAllActions();
457
        foreach ($actions as $action) {
458
            if ($this->buttonClickedFunc === $action->actionName()) {
459
                return $action;
460
            }
461
        }
462
        return null;
463
    }
464
465
    /**
466
     * Get a list of all actions, including those in the main "fields" FieldList
467
     *
468
     * @return array
469
     */
470
    protected function getAllActions()
471
    {
472
        $fields = $this->form->Fields()->dataFields();
473
        $actions = $this->form->Actions()->dataFields();
474
475
        $fieldsAndActions = array_merge($fields, $actions);
476
        $actions = array_filter($fieldsAndActions, function ($fieldOrAction) {
477
            return $fieldOrAction instanceof FormAction;
478
        });
479
480
        return $actions;
481
    }
482
483
    /**
484
     * Processing that occurs before a form is executed.
485
     *
486
     * This includes form validation, if it fails, we throw a ValidationException
487
     *
488
     * This includes form validation, if it fails, we redirect back
489
     * to the form with appropriate error messages.
490
     * Always return true if the current form action is exempt from validation
491
     *
492
     * Triggered through {@link httpSubmission()}.
493
     *
494
     *
495
     * Note that CSRF protection takes place in {@link httpSubmission()},
496
     * if it fails the form data will never reach this method.
497
     *
498
     * @return ValidationResult
499
     */
500
    public function validationResult()
501
    {
502
        // Check if button is exempt, or if there is no validator
503
        $action = $this->buttonClicked();
504
        $validator = $this->form->getValidator();
505
        if (!$validator || $this->form->actionIsValidationExempt($action)) {
0 ignored issues
show
introduced by
$validator is of type SilverStripe\Forms\Validator, thus it always evaluated to true.
Loading history...
506
            return ValidationResult::create();
507
        }
508
509
        // Invoke validator
510
        $result = $validator->validate();
511
        $this->form->loadMessagesFrom($result);
512
        return $result;
513
    }
514
515
    public function forTemplate()
516
    {
517
        return $this->form->forTemplate();
518
    }
519
520
    /**
521
     * @param $subject
522
     * @param string $funcName
523
     * @param HTTPRequest $request
524
     * @param array $vars
525
     * @return mixed
526
     */
527
    private function invokeFormHandler($subject, string $funcName, HTTPRequest $request, array $vars)
528
    {
529
        $this->extend('beforeCallFormHandler', $request, $funcName, $vars, $this->form, $subject);
530
        $result = $subject->$funcName($vars, $this->form, $request, $this);
531
        $this->extend('afterCallFormHandler', $request, $funcName, $vars, $this->form, $subject, $result);
532
533
        return $result;
534
    }
535
}
536