Commit 8b4bc7df authored by catch's avatar catch

Issue #2500527 by dawehner, tim.plunkett, effulgentsia: Rewrite...

Issue #2500527 by dawehner, tim.plunkett, effulgentsia: Rewrite \Drupal\file\Controller\FileWidgetAjaxController::upload() to not rely on form cache
parent acf91933
......@@ -821,7 +821,7 @@ services:
- { name: event_subscriber }
form_ajax_subscriber:
class: Drupal\Core\Form\EventSubscriber\FormAjaxSubscriber
arguments: ['@form_ajax_response_builder']
arguments: ['@form_ajax_response_builder', '@renderer', '@string_translation']
tags:
- { name: event_subscriber }
route_enhancer.lazy_collector:
......
......@@ -7,10 +7,16 @@
namespace Drupal\Core\Form\EventSubscriber;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\EventSubscriber\MainContentViewSubscriber;
use Drupal\Core\Form\Exception\BrokenPostRequestException;
use Drupal\Core\Form\FormAjaxException;
use Drupal\Core\Form\FormAjaxResponseBuilderInterface;
use Drupal\Core\Form\FormBuilderInterface;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
......@@ -21,6 +27,8 @@
*/
class FormAjaxSubscriber implements EventSubscriberInterface {
use StringTranslationTrait;
/**
* The form AJAX response builder.
*
......@@ -28,14 +36,27 @@ class FormAjaxSubscriber implements EventSubscriberInterface {
*/
protected $formAjaxResponseBuilder;
/**
* The renderer service.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* Constructs a new FormAjaxSubscriber.
*
* @param \Drupal\Core\Form\FormAjaxResponseBuilderInterface $form_ajax_response_builder
* The form AJAX response builder.
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer service.
* @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
* The string translation.
*/
public function __construct(FormAjaxResponseBuilderInterface $form_ajax_response_builder) {
public function __construct(FormAjaxResponseBuilderInterface $form_ajax_response_builder, RendererInterface $renderer, TranslationInterface $string_translation) {
$this->formAjaxResponseBuilder = $form_ajax_response_builder;
$this->renderer = $renderer;
$this->stringTranslation = $string_translation;
}
/**
......@@ -64,9 +85,24 @@ public function onView(GetResponseForControllerResultEvent $event) {
* The event to process.
*/
public function onException(GetResponseForExceptionEvent $event) {
$exception = $event->getException();
$request = $event->getRequest();
// Render a nice error message in case we have a file upload which exceeds
// the configured upload limit.
if ($exception instanceof BrokenPostRequestException && $request->query->has(FormBuilderInterface::AJAX_FORM_REQUEST)) {
$this->drupalSetMessage($this->t('An unrecoverable error occurred. The uploaded file likely exceeded the maximum file size (@size) that this server supports.', ['@size' => $this->formatSize($exception->getSize())]), 'error');
$response = new AjaxResponse();
$status_messages = ['#type' => 'status_messages'];
$response->addCommand(new ReplaceCommand(NULL, $this->renderer->renderRoot($status_messages)));
$response->headers->set('X-Status-Code', 200);
$event->setResponse($response);
return;
}
// Extract the form AJAX exception (it may have been passed to another
// exception before reaching here).
if ($exception = $this->getFormAjaxException($event->getException())) {
if ($exception = $this->getFormAjaxException($exception)) {
$request = $event->getRequest();
$form = $exception->getForm();
$form_state = $exception->getFormState();
......@@ -111,6 +147,16 @@ protected function getFormAjaxException(\Exception $e) {
return $exception;
}
/**
* Wraps format_size()
*
* @return string
* The formatted size.
*/
protected function formatSize($size) {
return format_size($size);
}
/**
* {@inheritdoc}
*/
......@@ -123,4 +169,13 @@ public static function getSubscribedEvents() {
return $events;
}
/**
* Wraps drupal_set_message().
*
* @codeCoverageIgnore
*/
protected function drupalSetMessage($message = NULL, $type = 'status', $repeat = FALSE) {
drupal_set_message($message, $type, $repeat);
}
}
<?php
/**
* @file
* Contains \Drupal\Core\Form\BrokenPostRequestException.
*/
namespace Drupal\Core\Form\Exception;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Defines an exception used, when the POST HTTP body is broken.
*/
class BrokenPostRequestException extends BadRequestHttpException {
/**
* The maximum upload size.
*
* @var string
*/
protected $size;
/**
* Constructs a new BrokenPostRequestException.
*
* @param string $max_upload_size
* The size of the maximum upload size.
* @param string $message
* The internal exception message.
* @param \Exception $previous
* The previous exception.
* @param int $code
* The internal exception code.
*/
public function __construct($max_upload_size, $message = NULL, \Exception $previous = NULL, $code = 0) {
parent::__construct($message, $previous, $code);
$this->size = $max_upload_size;
}
/**
* Returns the maximum upload size.
*
* @return string
* A translated string representation of the size of the file size limit
* based on the PHP upload_max_filesize and post_max_size.
*/
public function getSize() {
return $this->size;
}
}
......@@ -71,7 +71,7 @@ public function buildResponse(Request $request, array $form, FormStateInterface
if (empty($callback) || !is_callable($callback)) {
throw new HttpException(500, 'The specified #ajax callback is empty or not callable.');
}
$result = call_user_func_array($callback, [&$form, &$form_state]);
$result = call_user_func_array($callback, [&$form, &$form_state, $request]);
// If the callback is an #ajax callback, the result is a render array, and
// we need to turn it into an AJAX response, so that we can add any commands
......
......@@ -16,8 +16,11 @@
use Drupal\Core\DependencyInjection\ClassResolverInterface;
use Drupal\Core\EventSubscriber\MainContentViewSubscriber;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Form\Exception\BrokenPostRequestException;
use Drupal\Core\Render\Element;
use Drupal\Core\Render\ElementInfoManagerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Drupal\Core\Theme\ThemeManagerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RequestStack;
......@@ -30,6 +33,8 @@
*/
class FormBuilder implements FormBuilderInterface, FormValidatorInterface, FormSubmitterInterface, FormCacheInterface {
use StringTranslationTrait;
/**
* The module handler.
*
......@@ -264,6 +269,13 @@ public function buildForm($form_id, FormStateInterface &$form_state) {
// can use it to know or update information about the state of the form.
$response = $this->processForm($form_id, $form, $form_state);
// In case the post request exceeds the configured allowed size
// (post_max_size), the post request is potentially broken. Add some
// protection against that and at the same time have a nice error message.
if ($ajax_form_request && !isset($form_state->getUserInput()['form_id'])) {
throw new BrokenPostRequestException($this->getFileUploadMaxSize());
}
// After processing the form, if this is an AJAX form request, interrupt
// form rendering and return by throwing an exception that contains the
// processed form and form state. This exception will be caught by
......@@ -1151,6 +1163,17 @@ protected function buttonWasClicked($element, FormStateInterface &$form_state) {
return FALSE;
}
/**
* Wraps file_upload_max_size().
*
* @return string
* A translated string representation of the size of the file size limit
* based on the PHP upload_max_filesize and post_max_size.
*/
protected function getFileUploadMaxSize() {
return file_upload_max_size();
}
/**
* Gets the current active user.
*
......
......@@ -328,20 +328,6 @@
}
else if (this.element && element.form) {
this.url = this.$form.attr('action');
// @todo If there's a file input on this form, then jQuery will submit
// the AJAX response with a hidden Iframe rather than the XHR object.
// If the response to the submission is an HTTP redirect, then the
// Iframe will follow it, but the server won't content negotiate it
// correctly, because there won't be an ajax_iframe_upload POST
// variable. Until we figure out a work around to this problem, we
// prevent AJAX-enabling elements that submit to the same URL as the
// form when there's a file input. For example, this means the Delete
// button on the edit form of an Article node doesn't open its
// confirmation form in a dialog.
if (this.$form.find(':file').length) {
return;
}
}
}
......
file.ajax_upload:
path: '/file/ajax'
defaults:
_controller: '\Drupal\file\Controller\FileWidgetAjaxController::upload'
options:
_theme: ajax_base_page
requirements:
_permission: 'access content'
file.ajax_progress:
path: '/file/progress'
defaults:
......
......@@ -7,87 +7,14 @@
namespace Drupal\file\Controller;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\system\Controller\FormAjaxController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
/**
* Defines a controller to respond to file widget AJAX requests.
*/
class FileWidgetAjaxController extends FormAjaxController {
/**
* Processes AJAX file uploads and deletions.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The current request object.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* An AjaxResponse object.
*/
public function upload(Request $request) {
$form_parents = explode('/', $request->query->get('element_parents'));
$form_build_id = $request->query->get('form_build_id');
$request_form_build_id = $request->request->get('form_build_id');
if (empty($request_form_build_id) || $form_build_id !== $request_form_build_id) {
// Invalid request.
drupal_set_message(t('An unrecoverable error occurred. The uploaded file likely exceeded the maximum file size (@size) that this server supports.', array('@size' => format_size(file_upload_max_size()))), 'error');
$response = new AjaxResponse();
$status_messages = array('#type' => 'status_messages');
return $response->addCommand(new ReplaceCommand(NULL, $this->renderer->renderRoot($status_messages)));
}
try {
/** @var $ajaxForm \Drupal\system\FileAjaxForm */
$ajaxForm = $this->getForm($request);
$form = $ajaxForm->getForm();
$form_state = $ajaxForm->getFormState();
$commands = $ajaxForm->getCommands();
}
catch (HttpExceptionInterface $e) {
// Invalid form_build_id.
drupal_set_message(t('An unrecoverable error occurred. Use of this form has expired. Try reloading the page and submitting again.'), 'error');
$response = new AjaxResponse();
$status_messages = array('#type' => 'status_messages');
return $response->addCommand(new ReplaceCommand(NULL, $this->renderer->renderRoot($status_messages)));
}
// Get the current element and count the number of files.
$current_element = NestedArray::getValue($form, $form_parents);
$current_file_count = isset($current_element['#file_upload_delta']) ? $current_element['#file_upload_delta'] : 0;
// Process user input. $form and $form_state are modified in the process.
$this->formBuilder->processForm($form['#form_id'], $form, $form_state);
// Retrieve the element to be rendered.
$form = NestedArray::getValue($form, $form_parents);
// Add the special Ajax class if a new file was added.
if (isset($form['#file_upload_delta']) && $current_file_count < $form['#file_upload_delta']) {
$form[$current_file_count]['#attributes']['class'][] = 'ajax-new-content';
}
// Otherwise just add the new content class on a placeholder.
else {
$form['#suffix'] .= '<span class="ajax-new-content"></span>';
}
$status_messages = array('#type' => 'status_messages');
$form['#prefix'] .= $this->renderer->renderRoot($status_messages);
$output = $this->renderer->renderRoot($form);
$response = new AjaxResponse();
$response->setAttachments($form['#attached']);
foreach ($commands as $command) {
$response->addCommand($command, TRUE);
}
return $response->addCommand(new ReplaceCommand(NULL, $output));
}
/**
* Returns the progress status for a file upload process.
*
......
......@@ -7,11 +7,15 @@
namespace Drupal\file\Element;
use Drupal\Component\Utility\NestedArray;
use Drupal\Component\Utility\Html;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element\FormElement;
use Drupal\Core\Url;
use Drupal\file\Entity\File;
use Symfony\Component\HttpFoundation\Request;
/**
* Provides an AJAX/progress aware widget for uploading and saving a file.
......@@ -124,6 +128,53 @@ public static function valueCallback(&$element, $input, FormStateInterface $form
return $return;
}
/**
* #ajax callback for managed_file upload forms.
*
* This ajax callback takes care of the following things:
* - Ensures that broken requests due to too big files are caught.
* - Adds a class to the response to be able to highlight in the UI, that a
* new file got uploaded.
*
* @param array $form
* The build form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
* @param \Symfony\Component\HttpFoundation\Request $request
* The current request.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* The ajax response of the ajax upload.
*/
public static function uploadAjaxCallback(&$form, FormStateInterface &$form_state, Request $request) {
/** @var \Drupal\Core\Render\RendererInterface $renderer */
$renderer = \Drupal::service('renderer');
$form_parents = explode('/', $request->query->get('element_parents'));
// Retrieve the element to be rendered.
$form = NestedArray::getValue($form, $form_parents);
// Add the special AJAX class if a new file was added.
$current_file_count = $form_state->get('file_upload_delta_initial');
if (isset($form['#file_upload_delta']) && $current_file_count < $form['#file_upload_delta']) {
$form[$current_file_count]['#attributes']['class'][] = 'ajax-new-content';
}
// Otherwise just add the new content class on a placeholder.
else {
$form['#suffix'] .= '<span class="ajax-new-content"></span>';
}
$status_messages = ['#type' => 'status_messages'];
$form['#prefix'] .= $renderer->renderRoot($status_messages);
$output = $renderer->renderRoot($form);
$response = new AjaxResponse();
$response->setAttachments($form['#attached']);
return $response->addCommand(new ReplaceCommand(NULL, $output));
}
/**
* Render API callback: Expands the managed_file element type.
*
......@@ -146,12 +197,10 @@ public static function processManagedFile(&$element, FormStateInterface $form_st
$ajax_wrapper_id = Html::getUniqueId('ajax-wrapper');
$ajax_settings = [
// @todo Remove this in https://www.drupal.org/node/2500527.
'url' => Url::fromRoute('file.ajax_upload'),
'callback' => [get_called_class(), 'uploadAjaxCallback'],
'options' => [
'query' => [
'element_parents' => implode('/', $element['#array_parents']),
'form_build_id' => $complete_form['form_build_id']['#value'],
],
],
'wrapper' => $ajax_wrapper_id,
......
......@@ -407,18 +407,15 @@ public static function process($element, FormStateInterface $form_state, $form)
// file, the entire group of file fields is updated together.
if ($element['#cardinality'] != 1) {
$parents = array_slice($element['#array_parents'], 0, -1);
$new_url = Url::fromRoute('file.ajax_upload');
$new_options = array(
'query' => array(
'element_parents' => implode('/', $parents),
'form_build_id' => $form['form_build_id']['#value'],
),
);
$field_element = NestedArray::getValue($form, $parents);
$new_wrapper = $field_element['#id'] . '-ajax-wrapper';
foreach (Element::children($element) as $key) {
if (isset($element[$key]['#ajax'])) {
$element[$key]['#ajax']['url'] = $new_url->setOptions($new_options);
$element[$key]['#ajax']['options'] = $new_options;
$element[$key]['#ajax']['wrapper'] = $new_wrapper;
}
......@@ -451,6 +448,19 @@ public static function processMultiple($element, FormStateInterface $form_state,
$element_children = Element::children($element, TRUE);
$count = count($element_children);
// Count the number of already uploaded files, in order to display new
// items in \Drupal\file\Element\ManagedFile::uploadAjaxCallback().
if (!$form_state->isRebuilding()) {
$count_items_before = 0;
foreach ($element_children as $children) {
if (!empty($element[$children]['#default_value']['fids'])) {
$count_items_before++;
}
}
$form_state->set('file_upload_delta_initial', $count_items_before);
}
foreach ($element_children as $delta => $key) {
if ($key != $element['#file_upload_delta']) {
$description = static::getDescriptionFromElement($element[$key]);
......
......@@ -64,7 +64,7 @@ public function testBlockForms() {
$this->drupalPostAjaxForm(NULL, ['test1' => 'option1'], 'test1');
$this->assertOptionSelectedWithDrupalSelector('edit-test1', 'option1');
$this->assertOptionWithDrupalSelector('edit-test1', 'option3');
$this->drupalPostForm($this->getUrl(), ['test1' => 'option1'], 'Submit');
$this->drupalPostForm(NULL, ['test1' => 'option1'], 'Submit');
$this->assertText('Submission successful.');
}
......
......@@ -7,13 +7,17 @@
namespace Drupal\Tests\Core\Form\EventSubscriber;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Form\EventSubscriber\FormAjaxSubscriber;
use Drupal\Core\Form\Exception\BrokenPostRequestException;
use Drupal\Core\Form\FormAjaxException;
use Drupal\Core\Form\FormBuilderInterface;
use Drupal\Core\Form\FormState;
use Drupal\Tests\UnitTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\HttpKernelInterface;
......@@ -38,6 +42,20 @@ class FormAjaxSubscriberTest extends UnitTestCase {
*/
protected $httpKernel;
/**
* The renderer service.
*
* @var \Drupal\Core\Render\RendererInterface|\PHPUnit_Framework_MockObject_MockObject
*/
protected $renderer;
/**
* The mocked string translation.
*
* @var \Drupal\Core\StringTranslation\TranslationInterface|\PHPUnit_Framework_MockObject_MockObject
*/
protected $stringTranslation;
/**
* {@inheritdoc}
*/
......@@ -46,7 +64,9 @@ protected function setUp() {
$this->httpKernel = $this->getMock('Symfony\Component\HttpKernel\HttpKernelInterface');
$this->formAjaxResponseBuilder = $this->getMock('Drupal\Core\Form\FormAjaxResponseBuilderInterface');
$this->subscriber = new FormAjaxSubscriber($this->formAjaxResponseBuilder);
$this->renderer = $this->getMock('\Drupal\Core\Render\RendererInterface');
$this->stringTranslation = $this->getStringTranslationStub();
$this->subscriber = new FormAjaxSubscriber($this->formAjaxResponseBuilder, $this->renderer, $this->stringTranslation);
}
/**
......@@ -133,6 +153,46 @@ public function testOnExceptionResponseBuilderException() {
$this->assertSame($expected_exception, $event->getException());
}
/**
* @covers ::onException
*/
public function testOnExceptionBrokenPostRequest() {
$this->formAjaxResponseBuilder->expects($this->never())
->method('buildResponse');
$this->subscriber = $this->getMockBuilder('\Drupal\Core\Form\EventSubscriber\FormAjaxSubscriber')
->setConstructorArgs([$this->formAjaxResponseBuilder, $this->renderer, $this->getStringTranslationStub()])
->setMethods(['drupalSetMessage', 'formatSize'])
->getMock();
$this->subscriber->expects($this->once())
->method('drupalSetMessage')
->willReturn('asdf');
$this->subscriber->expects($this->once())
->method('formatSize')
->with(32 * 1e6)
->willReturn('32M');
$rendered_output = 'the rendered output';
$this->renderer->expects($this->once())
->method('renderRoot')
->willReturn($rendered_output);
$exception = new BrokenPostRequestException(32 * 1e6);
$request = new Request([FormBuilderInterface::AJAX_FORM_REQUEST => TRUE]);
$event = new GetResponseForExceptionEvent($this->httpKernel, $request, HttpKernelInterface::MASTER_REQUEST, $exception);
$this->subscriber->onException($event);
$actual_response = $event->getResponse();
$this->assertInstanceOf('\Drupal\Core\Ajax\AjaxResponse', $actual_response);
$this->assertSame(200, $actual_response->headers->get('X-Status-Code'));
$expected_commands[] = [
'command' => 'insert',
'method' => 'replaceWith',
'selector' => NULL,
'data' => $rendered_output,
'settings' => NULL,
];
$this->assertSame($expected_commands, $actual_response->getCommands());
}
/**
* @covers ::onException
* @covers ::getFormAjaxException
......
......@@ -9,10 +9,13 @@
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Form\EnforcedResponseException;
use Drupal\Core\Form\FormBuilderInterface;
use Drupal\Core\Form\FormInterface;
use Drupal\Core\Form\FormState;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* @coversDefaultClass \Drupal\Core\Form\FormBuilder
......@@ -423,6 +426,29 @@ public function testFormCacheDeletionUncached() {
$this->simulateFormSubmission($form_id, $form_arg, $form_state);
}
/**
* @covers ::buildForm
*
* @expectedException \Drupal\Core\Form\Exception\BrokenPostRequestException
*/
public function testExceededFileSize() {
$request = new Request([FormBuilderInterface::AJAX_FORM_REQUEST => TRUE]);
$request_stack = new RequestStack();
$request_stack->push($request);
$this->formBuilder = $this->getMockBuilder('\Drupal\Core\Form\FormBuilder')
->setConstructorArgs([$this->formValidator, $this->formSubmitter, $this->formCache, $this->moduleHandler, $this->eventDispatcher, $request_stack, $this->classResolver, $this->elementInfo, $this->themeManager, $this->csrfToken])
->setMethods(['getFileUploadMaxSize'])
->getMock();
$this->formBuilder->expects($this->once())
->method('getFileUploadMaxSize')
->willReturn(33554432);
$form_arg = $this->getMockForm('test_form_id');
$form_state = new FormState();
$this->formBuilder->buildForm($form_arg, $form_state);
}
}
class TestForm implements FormInterface {
......
......@@ -149,7 +149,12 @@ abstract class FormTestBase extends UnitTestCase {
*/
protected $themeManager;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->moduleHandler = $this->getMock('Drupal\Core\Extension\ModuleHandlerInterface');
$this->formCache = $this->getMock('Drupal\Core\Form\FormCacheInterface');
......@@ -189,7 +194,7 @@ protected function setUp() {
->getMock();
$this->root = dirname(dirname(substr(__DIR__, 0, -strlen(__NAMESPACE__))));
$this->formBuilder = new FormBuilder($this->formValidator, $this->formSubmitter, $this->formCache, $this->moduleHandler, $this->eventDispatcher, $this->requestStack, $this->classResolver, $this->elementInfo, $this->themeManager, $this->csrfToken, $this->kernel);
$this->formBuilder = new FormBuilder($this->formValidator, $this->formSubmitter, $this->formCache, $this->moduleHandler, $this->eventDispatcher, $this->requestStack, $this->classResolver, $this->elementInfo, $this->themeManager, $this->csrfToken);
}
/**
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment