Completed
Push — master ( 644ae6...bba86b )
by Daniel
10:38
created

FormRequestHandler::buttonClicked()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 6
nc 3
nop 0
dl 0
loc 10
rs 9.4285
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(
30
        'handleField',
31
        'httpSubmission',
32
        'forTemplate',
33
    );
34
35
    /**
36
     * @config
37
     * @var array
38
     */
39
    private static $url_handlers = array(
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()),
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
                    "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);
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
                    "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
                        "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, get_class($this))
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);
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);
237
            }
238
239
            // Check for inline actions
240
            $field = $this->checkFieldsForAction($this->form->Fields(), $funcName);
241
            if ($field) {
242
                return $field->$funcName($vars, $this->form, $request);
243
            }
244
        } catch (ValidationException $e) {
245
            // The ValdiationResult contains all the relevant metadata
246
            $result = $e->getResult();
247
            $this->form->loadMessagesFrom($result);
248
            return $this->getValidationErrorResponse($result);
249
        }
250
251
        // Determine if legacy form->allowed_actions is set
252
        $legacyActions = $this->form->config()->get('allowed_actions');
253
        if ($legacyActions) {
254
            throw new BadMethodCallException(
255
                "allowed_actions are not valid on Form class " . get_class($this->form) .
256
                ". Implement these in subclasses of " . get_class($this) . " instead"
257
            );
258
        }
259
260
        return $this->httpError(404);
261
    }
262
263
    /**
264
     * @param string $action
265
     * @return bool
266
     */
267
    public function checkAccessAction($action)
268
    {
269
        if (parent::checkAccessAction($action)) {
270
            return true;
271
        }
272
273
        $actions = $this->getAllActions();
274
        foreach ($actions as $formAction) {
275
            if ($formAction->actionName() === $action) {
276
                return true;
277
            }
278
        }
279
280
            // Always allow actions on fields
281
        $field = $this->checkFieldsForAction($this->form->Fields(), $action);
282
        if ($field && $field->checkAccessAction($action)) {
283
            return true;
284
        }
285
286
        return false;
287
    }
288
289
290
291
    /**
292
     * Returns the appropriate response up the controller chain
293
     * if {@link validate()} fails (which is checked prior to executing any form actions).
294
     * By default, returns different views for ajax/non-ajax request, and
295
     * handles 'application/json' requests with a JSON object containing the error messages.
296
     * Behaviour can be influenced by setting {@link $redirectToFormOnValidationError},
297
     * and can be overruled by setting {@link $validationResponseCallback}.
298
     *
299
     * @param ValidationResult $result
300
     * @return HTTPResponse
301
     */
302
    protected function getValidationErrorResponse(ValidationResult $result)
303
    {
304
        // Check for custom handling mechanism
305
        $callback = $this->form->getValidationResponseCallback();
306
        if ($callback && $callbackResponse = call_user_func($callback, $result)) {
307
            return $callbackResponse;
308
        }
309
310
        // Check if handling via ajax
311
        if ($this->getRequest()->isAjax()) {
312
            return $this->getAjaxErrorResponse($result);
313
        }
314
315
        // Prior to redirection, persist this result in session to re-display on redirect
316
        $this->form->setSessionValidationResult($result);
317
        $this->form->setSessionData($this->form->getData());
318
319
        // Determine redirection method
320
        if ($this->form->getRedirectToFormOnValidationError()) {
321
            return $this->redirectBackToForm();
322
        }
323
        return $this->redirectBack();
324
    }
325
326
    /**
327
     * Redirect back to this form with an added #anchor link
328
     *
329
     * @return HTTPResponse
330
     */
331
    public function redirectBackToForm()
332
    {
333
        $pageURL = $this->getReturnReferer();
334
        if (!$pageURL) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $pageURL of type string|null 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...
335
            return $this->redirectBack();
336
        }
337
338
        // Add backURL and anchor
339
        $pageURL = Controller::join_links(
340
            $this->addBackURLParam($pageURL),
341
            '#' . $this->form->FormName()
342
        );
343
344
        // Redirect
345
        return $this->redirect($pageURL);
346
    }
347
348
    /**
349
     * Helper to add ?BackURL= to any link
350
     *
351
     * @param string $link
352
     * @return string
353
     */
354
    protected function addBackURLParam($link)
355
    {
356
        $backURL = $this->getBackURL();
357
        if ($backURL) {
358
            return Controller::join_links($link, '?BackURL=' . urlencode($backURL));
359
        }
360
        return $link;
361
    }
362
363
    /**
364
     * Build HTTP error response for ajax requests
365
     *
366
     * @internal called from {@see Form::getValidationErrorResponse}
367
     * @param ValidationResult $result
368
     * @return HTTPResponse
369
     */
370
    protected function getAjaxErrorResponse(ValidationResult $result)
371
    {
372
        // Ajax form submissions accept json encoded errors by default
373
        $acceptType = $this->getRequest()->getHeader('Accept');
374
        if (strpos($acceptType, 'application/json') !== false) {
375
            // Send validation errors back as JSON with a flag at the start
376
            $response = new HTTPResponse(Convert::array2json($result->getMessages()));
377
            $response->addHeader('Content-Type', 'application/json');
378
            return $response;
379
        }
380
381
        // Send the newly rendered form tag as HTML
382
        $this->form->loadMessagesFrom($result);
383
        $response = new HTTPResponse($this->form->forTemplate());
384
        $response->addHeader('Content-Type', 'text/html');
385
        return $response;
386
    }
387
388
    /**
389
     * Fields can have action to, let's check if anyone of the responds to $funcname them
390
     *
391
     * @param SS_List|array $fields
392
     * @param callable $funcName
393
     * @return FormField
394
     */
395
    protected function checkFieldsForAction($fields, $funcName)
396
    {
397
        foreach ($fields as $field) {
398
            /** @skipUpgrade */
399
            if (ClassInfo::hasMethod($field, 'FieldList')) {
400
                if ($field = $this->checkFieldsForAction($field->FieldList(), $funcName)) {
401
                    return $field;
402
                }
403
            } elseif ($field->hasMethod($funcName) && $field->checkAccessAction($funcName)) {
404
                return $field;
405
            }
406
        }
407
        return null;
408
    }
409
410
    /**
411
     * Handle a field request.
412
     * Uses {@link Form->dataFieldByName()} to find a matching field,
413
     * and falls back to {@link FieldList->fieldByName()} to look
414
     * for tabs instead. This means that if you have a tab and a
415
     * formfield with the same name, this method gives priority
416
     * to the formfield.
417
     *
418
     * @param HTTPRequest $request
419
     * @return FormField
420
     */
421
    public function handleField($request)
422
    {
423
        $field = $this->form->Fields()->dataFieldByName($request->param('FieldName'));
424
425
        if ($field) {
426
            return $field;
427
        } else {
428
            // falling back to fieldByName, e.g. for getting tabs
429
            return $this->form->Fields()->fieldByName($request->param('FieldName'));
430
        }
431
    }
432
433
    /**
434
     * Sets the button that was clicked.  This should only be called by the Controller.
435
     *
436
     * @param callable $funcName The name of the action method that will be called.
437
     * @return $this
438
     */
439
    public function setButtonClicked($funcName)
440
    {
441
        $this->buttonClickedFunc = $funcName;
442
        return $this;
443
    }
444
445
    /**
446
     * Get instance of button which was clicked for this request
447
     *
448
     * @return FormAction
449
     */
450
    public function buttonClicked()
451
    {
452
        $actions = $this->getAllActions();
453
        foreach ($actions as $action) {
454
            if ($this->buttonClickedFunc === $action->actionName()) {
455
                return $action;
456
            }
457
        }
458
        return null;
459
    }
460
461
    /**
462
     * Get a list of all actions, including those in the main "fields" FieldList
463
     *
464
     * @return array
465
     */
466
    protected function getAllActions()
467
    {
468
        $fields = $this->form->Fields()->dataFields();
469
        $actions = $this->form->Actions()->dataFields();
470
471
        $fieldsAndActions = array_merge($fields, $actions);
472
        $actions = array_filter($fieldsAndActions, function ($fieldOrAction) {
473
            return $fieldOrAction instanceof FormAction;
474
        });
475
476
        return $actions;
477
    }
478
479
    /**
480
     * Processing that occurs before a form is executed.
481
     *
482
     * This includes form validation, if it fails, we throw a ValidationException
483
     *
484
     * This includes form validation, if it fails, we redirect back
485
     * to the form with appropriate error messages.
486
     * Always return true if the current form action is exempt from validation
487
     *
488
     * Triggered through {@link httpSubmission()}.
489
     *
490
     *
491
     * Note that CSRF protection takes place in {@link httpSubmission()},
492
     * if it fails the form data will never reach this method.
493
     *
494
     * @return ValidationResult
495
     */
496
    public function validationResult()
497
    {
498
        // Check if button is exempt, or if there is no validator
499
        $action = $this->buttonClicked();
500
        $validator = $this->form->getValidator();
501
        if (!$validator || $this->form->actionIsValidationExempt($action)) {
502
            return ValidationResult::create();
503
        }
504
505
        // Invoke validator
506
        $result = $validator->validate();
507
        $this->form->loadMessagesFrom($result);
508
        return $result;
509
    }
510
}
511