'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; } }