mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
519 lines
17 KiB
PHP
519 lines
17 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 = array(
|
|
'handleField',
|
|
'httpSubmission',
|
|
'forTemplate',
|
|
);
|
|
|
|
/**
|
|
* @config
|
|
* @var array
|
|
*/
|
|
private static $url_handlers = array(
|
|
'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 = array();
|
|
parse_str($paramVars, $newRequestParams);
|
|
$vars = array_merge((array)$vars, (array)$newRequestParams);
|
|
}
|
|
|
|
// Cleanup action_, _x and _y from image fields
|
|
$funcName = preg_replace(array('/^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 Valiator 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();
|
|
if ($controller && $controller->hasMethod($funcName)) {
|
|
$controller->setRequest($request);
|
|
return $controller->$funcName($vars, $this->form, $request, $this);
|
|
}
|
|
|
|
// Otherwise, try a handler method on the form request handler.
|
|
if ($this->hasMethod($funcName)) {
|
|
return $this->$funcName($vars, $this->form, $request, $this);
|
|
}
|
|
|
|
// Otherwise, try a handler method on the form itself
|
|
if ($this->form->hasMethod($funcName)) {
|
|
return $this->form->$funcName($vars, $this->form, $request, $this);
|
|
}
|
|
|
|
// Check for inline actions
|
|
$field = $this->checkFieldsForAction($this->form->Fields(), $funcName);
|
|
if ($field) {
|
|
return $field->$funcName($vars, $this->form, $request, $this);
|
|
}
|
|
} catch (ValidationException $e) {
|
|
// The ValdiationResult 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);
|
|
}
|
|
|
|
/**
|
|
* @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();
|
|
}
|
|
}
|