silverstripe-framework/src/Forms/FormRequestHandler.php

536 lines
18 KiB
PHP

<?php
namespace SilverStripe\Forms;
use BadMethodCallException;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Control\HTTPResponse_Exception;
use SilverStripe\Control\RequestHandler;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Convert;
use SilverStripe\ORM\ValidationResult;
use SilverStripe\ORM\SS_List;
use SilverStripe\ORM\ValidationException;
class FormRequestHandler extends RequestHandler
{
/**
* @var callable|null
*/
protected $buttonClickedFunc;
/**
* @config
* @var array
*/
private static $allowed_actions = [
'handleField',
'httpSubmission',
'forTemplate',
];
/**
* @config
* @var array
*/
private static $url_handlers = [
'field/$FieldName!' => 'handleField',
'POST ' => 'httpSubmission',
'GET ' => 'httpSubmission',
'HEAD ' => 'httpSubmission',
];
/**
* Form model being handled
*
* @var Form
*/
protected $form = null;
/**
* Build a new request handler for a given Form model
*
* @param Form $form
*/
public function __construct(Form $form)
{
$this->form = $form;
parent::__construct();
// Inherit parent controller request
$parent = $this->form->getController();
if ($parent) {
$this->setRequest($parent->getRequest());
}
}
/**
* Get link for this form
*
* @param string $action
* @return string
*/
public function Link($action = null)
{
// Forms without parent controller have no link;
// E.g. Submission handled via graphql
$controller = $this->form->getController();
if (empty($controller)) {
return null;
}
// Respect FormObjectLink() method
if ($controller->hasMethod("FormObjectLink")) {
$base = $controller->FormObjectLink($this->form->getName());
} else {
$base = Controller::join_links($controller->Link(), $this->form->getName());
}
// Join with action and decorate
$link = Controller::join_links($base, $action, '/');
$this->extend('updateLink', $link, $action);
return $link;
}
/**
* Handle a form submission. GET and POST requests behave identically.
* Populates the form with {@link loadDataFrom()}, calls {@link validate()},
* and only triggers the requested form action/method
* if the form is valid.
*
* @param HTTPRequest $request
* @return HTTPResponse
* @throws HTTPResponse_Exception
*/
public function httpSubmission($request)
{
// Strict method check
if ($this->form->getStrictFormMethodCheck()) {
// Throws an error if the method is bad...
$allowedMethod = $this->form->FormMethod();
if ($allowedMethod !== $request->httpMethod()) {
$response = Controller::curr()->getResponse();
$response->addHeader('Allow', $allowedMethod);
$this->httpError(405, _t(
"SilverStripe\\Forms\\Form.BAD_METHOD",
"This form requires a {method} submission",
['method' => $allowedMethod]
));
}
// ...and only uses the variables corresponding to that method type
$vars = $allowedMethod === 'GET'
? $request->getVars()
: $request->postVars();
} else {
$vars = $request->requestVars();
}
// Ensure we only process saveable fields (non structural, readonly, or disabled)
$allowedFields = array_keys($this->form->Fields()->saveableFields() ?? []);
// Populate the form
$this->form->loadDataFrom($vars, true, $allowedFields);
// Protection against CSRF attacks
// @todo Move this to SecurityTokenField::validate()
$token = $this->form->getSecurityToken();
if (! $token->checkRequest($request)) {
$securityID = $token->getName();
if (empty($vars[$securityID])) {
$this->httpError(400, _t(
"SilverStripe\\Forms\\Form.CSRF_FAILED_MESSAGE",
"There seems to have been a technical problem. Please click the back button, " . "refresh your browser, and try again."
));
} else {
// Clear invalid token on refresh
$this->form->clearFormState();
$data = $this->form->getData();
unset($data[$securityID]);
$this->form
->setSessionData($data)
->sessionError(_t(
"SilverStripe\\Forms\\Form.CSRF_EXPIRED_MESSAGE",
"Your session has expired. Please re-submit the form."
));
// Return the user
return $this->redirectBack();
}
}
// Determine the action button clicked
$funcName = null;
foreach ($vars as $paramName => $paramVal) {
if (substr($paramName ?? '', 0, 7) == 'action_') {
// Break off querystring arguments included in the action
if (strpos($paramName ?? '', '?') !== false) {
list($paramName, $paramVars) = explode('?', $paramName ?? '', 2);
$newRequestParams = [];
parse_str($paramVars ?? '', $newRequestParams);
$vars = array_merge((array)$vars, (array)$newRequestParams);
}
// Cleanup action_, _x and _y from image fields
$funcName = preg_replace(['/^action_/','/_x$|_y$/'], '', $paramName ?? '');
break;
}
}
// If the action wasn't set, choose the default on the form.
if (!isset($funcName) && $defaultAction = $this->form->defaultAction()) {
$funcName = $defaultAction->actionName();
}
if (isset($funcName)) {
$this->setButtonClicked($funcName);
}
// Permission checks (first on controller, then falling back to request handler)
$controller = $this->form->getController();
if (// Ensure that the action is actually a button or method on the form,
// and not just a method on the controller.
$controller
&& $controller->hasMethod($funcName)
&& !$controller->checkAccessAction($funcName)
// If a button exists, allow it on the controller
// buttonClicked() validates that the action set above is valid
&& !$this->buttonClicked()
) {
return $this->httpError(
403,
sprintf('Action "%s" not allowed on controller (Class: %s)', $funcName, get_class($controller))
);
} elseif (// No checks for button existence or $allowed_actions is performed -
// all form methods are callable (e.g. the legacy "callfieldmethod()")
$this->hasMethod($funcName)
&& !$this->checkAccessAction($funcName)
) {
return $this->httpError(
403,
sprintf('Action "%s" not allowed on form request handler (Class: "%s")', $funcName, static::class)
);
}
// Action handlers may throw ValidationExceptions.
try {
// Or we can use the Validator attached to the form
$result = $this->form->validationResult();
if (!$result->isValid()) {
return $this->getValidationErrorResponse($result);
}
// First, try a handler method on the controller (has been checked for allowed_actions above already)
$controller = $this->form->getController();
$args = [$funcName, $request, $vars];
if ($controller && $controller->hasMethod($funcName)) {
$controller->setRequest($request);
return $this->invokeFormHandler($controller, ...$args);
}
// Otherwise, try a handler method on the form request handler.
if ($this->hasMethod($funcName)) {
return $this->invokeFormHandler($this, ...$args);
}
// Otherwise, try a handler method on the form itself
if ($this->form->hasMethod($funcName)) {
return $this->invokeFormHandler($this->form, ...$args);
}
// Check for inline actions
$field = $this->checkFieldsForAction($this->form->Fields(), $funcName);
if ($field) {
return $this->invokeFormHandler($field, ...$args);
}
} catch (ValidationException $e) {
// The ValidationResult contains all the relevant metadata
$result = $e->getResult();
$this->form->loadMessagesFrom($result);
return $this->getValidationErrorResponse($result);
}
// Determine if legacy form->allowed_actions is set
$legacyActions = $this->form->config()->get('allowed_actions');
if ($legacyActions) {
throw new BadMethodCallException(
"allowed_actions are not valid on Form class " . get_class($this->form) . ". Implement these in subclasses of " . static::class . " instead"
);
}
return $this->httpError(404, "Could not find a suitable form-action callback function");
}
/**
* @param string $action
* @return bool
*/
public function checkAccessAction($action)
{
if (parent::checkAccessAction($action)) {
return true;
}
$actions = $this->getAllActions();
foreach ($actions as $formAction) {
if ($formAction->actionName() === $action) {
return true;
}
}
// Always allow actions on fields
$field = $this->checkFieldsForAction($this->form->Fields(), $action);
if ($field && $field->checkAccessAction($action)) {
return true;
}
return false;
}
/**
* Returns the appropriate response up the controller chain
* if {@link validate()} fails (which is checked prior to executing any form actions).
* By default, returns different views for ajax/non-ajax request, and
* handles 'application/json' requests with a JSON object containing the error messages.
* Behaviour can be influenced by setting {@link $redirectToFormOnValidationError},
* and can be overruled by setting {@link $validationResponseCallback}.
*
* @param ValidationResult $result
* @return HTTPResponse
*/
protected function getValidationErrorResponse(ValidationResult $result)
{
// Check for custom handling mechanism
$callback = $this->form->getValidationResponseCallback();
if ($callback && $callbackResponse = call_user_func($callback, $result)) {
return $callbackResponse;
}
// Check if handling via ajax
if ($this->getRequest()->isAjax()) {
return $this->getAjaxErrorResponse($result);
}
// Prior to redirection, persist this result in session to re-display on redirect
$this->form->setSessionValidationResult($result);
$this->form->setSessionData($this->form->getData());
// Determine redirection method
if ($this->form->getRedirectToFormOnValidationError()) {
return $this->redirectBackToForm();
}
return $this->redirectBack();
}
/**
* Redirect back to this form with an added #anchor link
*
* @return HTTPResponse
*/
public function redirectBackToForm()
{
$pageURL = $this->getReturnReferer();
if (!$pageURL) {
return $this->redirectBack();
}
// Add backURL and anchor
$pageURL = Controller::join_links(
$this->addBackURLParam($pageURL),
'#' . $this->form->FormName()
);
// Redirect
return $this->redirect($pageURL);
}
/**
* Helper to add ?BackURL= to any link
*
* @param string $link
* @return string
*/
protected function addBackURLParam($link)
{
$backURL = $this->getBackURL();
if ($backURL) {
return Controller::join_links($link, '?BackURL=' . urlencode($backURL ?? ''));
}
return $link;
}
/**
* Build HTTP error response for ajax requests
*
* @internal called from {@see Form::getValidationErrorResponse}
* @param ValidationResult $result
* @return HTTPResponse
*/
protected function getAjaxErrorResponse(ValidationResult $result)
{
// Ajax form submissions accept json encoded errors by default
$acceptType = $this->getRequest()->getHeader('Accept');
if (strpos($acceptType ?? '', 'application/json') !== false) {
// Send validation errors back as JSON with a flag at the start
$response = new HTTPResponse(json_encode($result->getMessages()));
$response->addHeader('Content-Type', 'application/json');
return $response;
}
// Send the newly rendered form tag as HTML
$this->form->loadMessagesFrom($result);
$response = new HTTPResponse($this->form->forTemplate());
$response->addHeader('Content-Type', 'text/html');
return $response;
}
/**
* Fields can have action to, let's check if anyone of the responds to $funcname them
*
* @param SS_List|array $fields
* @param callable $funcName
* @return FormField
*/
protected function checkFieldsForAction($fields, $funcName)
{
foreach ($fields as $field) {
/** @skipUpgrade */
if (ClassInfo::hasMethod($field, 'FieldList')) {
if ($field = $this->checkFieldsForAction($field->FieldList(), $funcName)) {
return $field;
}
} elseif ($field->hasMethod($funcName) && $field->checkAccessAction($funcName)) {
return $field;
}
}
return null;
}
/**
* Handle a field request.
* Uses {@link Form->dataFieldByName()} to find a matching field,
* and falls back to {@link FieldList->fieldByName()} to look
* for tabs instead. This means that if you have a tab and a
* formfield with the same name, this method gives priority
* to the formfield.
*
* @param HTTPRequest $request
* @return FormField
*/
public function handleField($request)
{
$field = $this->form->Fields()->dataFieldByName($request->param('FieldName'));
if ($field) {
return $field;
} else {
// falling back to fieldByName, e.g. for getting tabs
return $this->form->Fields()->fieldByName($request->param('FieldName'));
}
}
/**
* Sets the button that was clicked. This should only be called by the Controller.
*
* @param callable $funcName The name of the action method that will be called.
* @return $this
*/
public function setButtonClicked($funcName)
{
$this->buttonClickedFunc = $funcName;
return $this;
}
/**
* Get instance of button which was clicked for this request
*
* @return FormAction
*/
public function buttonClicked()
{
$actions = $this->getAllActions();
foreach ($actions as $action) {
if ($this->buttonClickedFunc === $action->actionName()) {
return $action;
}
}
return null;
}
/**
* Get a list of all actions, including those in the main "fields" FieldList
*
* @return array
*/
protected function getAllActions()
{
$fields = $this->form->Fields()->dataFields();
$actions = $this->form->Actions()->dataFields();
$fieldsAndActions = array_merge($fields, $actions);
$actions = array_filter($fieldsAndActions ?? [], function ($fieldOrAction) {
return $fieldOrAction instanceof FormAction;
});
return $actions;
}
/**
* Processing that occurs before a form is executed.
*
* This includes form validation, if it fails, we throw a ValidationException
*
* This includes form validation, if it fails, we redirect back
* to the form with appropriate error messages.
* Always return true if the current form action is exempt from validation
*
* Triggered through {@link httpSubmission()}.
*
*
* Note that CSRF protection takes place in {@link httpSubmission()},
* if it fails the form data will never reach this method.
*
* @return ValidationResult
*/
public function validationResult()
{
// Check if button is exempt, or if there is no validator
$action = $this->buttonClicked();
$validator = $this->form->getValidator();
if (!$validator || $this->form->actionIsValidationExempt($action)) {
return ValidationResult::create();
}
// Invoke validator
$result = $validator->validate();
$this->form->loadMessagesFrom($result);
return $result;
}
public function forTemplate()
{
return $this->form->forTemplate();
}
/**
* @param $subject
* @param string $funcName
* @param HTTPRequest $request
* @param array $vars
* @return mixed
*/
private function invokeFormHandler($subject, string $funcName, HTTPRequest $request, array $vars)
{
$this->extend('beforeCallFormHandler', $request, $funcName, $vars, $this->form, $subject);
$result = $subject->$funcName($vars, $this->form, $request, $this);
$this->extend('afterCallFormHandler', $request, $funcName, $vars, $this->form, $subject, $result);
return $result;
}
}