diff --git a/core/modules/rest/src/Plugin/views/display/RestExport.php b/core/modules/rest/src/Plugin/views/display/RestExport.php index cd961954a4..582a9c4719 100644 --- a/core/modules/rest/src/Plugin/views/display/RestExport.php +++ b/core/modules/rest/src/Plugin/views/display/RestExport.php @@ -70,7 +70,7 @@ class RestExport extends PathPluginBase implements ResponseDisplayPluginInterfac * * @var string */ - protected $mimeType; + protected $mimeType = 'application/json'; /** * The renderer. @@ -140,20 +140,13 @@ public function initDisplay(ViewExecutable $view, array &$display, array &$optio parent::initDisplay($view, $display, $options); $request_content_type = $this->view->getRequest()->getRequestFormat(); - // Only use the requested content type if it's not 'html'. If it is then - // default to 'json' to aid debugging. - // @todo Remove the need for this when we have better content negotiation. - if ($request_content_type != 'html') { + + // This allows still falling back to the default for things like views + // preview. + if ($request_content_type !== 'html') { $this->setContentType($request_content_type); + $this->setMimeType($this->view->getRequest()->getMimeType($request_content_type)); } - // If the requested content type is 'html' and the default 'json' is not - // selected as a format option in the view display, fallback to the first - // format in the array. - elseif (!empty($options['style']['options']['formats']) && !isset($options['style']['options']['formats'][$this->getContentType()])) { - $this->setContentType(reset($options['style']['options']['formats'])); - } - - $this->setMimeType($this->view->getRequest()->getMimeType($this->contentType)); } /** @@ -327,17 +320,20 @@ public function collectRoutes(RouteCollection $collection) { if ($route = $collection->get("view.$view_id.$display_id")) { $style_plugin = $this->getPlugin('style'); - // REST exports should only respond to get methods. + // REST exports should only respond to GET methods. $route->setMethods(['GET']); + $formats = $style_plugin->getFormats(); + + // If there are no configured formats, add all formats that the serializer + // style plugin supports. + if (!$formats) { + $formats = $style_plugin->getFormatOptions(); + } + // Format as a string using pipes as a delimiter. - if ($formats = $style_plugin->getFormats()) { - // Allow a REST Export View to be returned with an HTML-only accept - // format. That allows browsers or other non-compliant systems to access - // the view, as it is unlikely to have a conflicting HTML representation - // anyway. - $route->setRequirement('_format', implode('|', $formats + ['html'])); - } + $route->setRequirement('_format', implode('|', $formats)); + // Add authentication to the route if it was set. If no authentication was // set, the default authentication will be used, which is cookie based by // default. @@ -421,15 +417,15 @@ public function render() { $build['#suffix'] = ''; unset($build['#markup']); } - elseif ($this->view->getRequest()->getFormat($this->view->element['#content_type']) !== 'html') { - // This display plugin is primarily for returning non-HTML formats. - // However, we still invoke the renderer to collect cacheability metadata. - // Because the renderer is designed for HTML rendering, it filters - // #markup for XSS unless it is already known to be safe, but that filter - // only works for HTML. Therefore, we mark the contents as safe to bypass - // the filter. So long as we are returning this in a non-HTML response - // (checked above), this is safe, because an XSS attack only works when - // executed by an HTML agent. + else { + // This display plugin is for returning non-HTML formats. However, we + // still invoke the renderer to collect cacheability metadata. Because the + // renderer is designed for HTML rendering, it filters #markup for XSS + // unless it is already known to be safe, but that filter only works for + // HTML. Therefore, we mark the contents as safe to bypass the filter. So + // long as we are returning this in a non-HTML response, + // this is safe, because an XSS attack only works when executed by an HTML + // agent. // @todo Decide how to support non-HTML in the render API in // https://www.drupal.org/node/2501313. $build['#markup'] = ViewsRenderPipelineMarkup::create($build['#markup']); diff --git a/core/modules/rest/src/Plugin/views/style/Serializer.php b/core/modules/rest/src/Plugin/views/style/Serializer.php index 895b9fe1ae..51c126f6ab 100644 --- a/core/modules/rest/src/Plugin/views/style/Serializer.php +++ b/core/modules/rest/src/Plugin/views/style/Serializer.php @@ -197,7 +197,7 @@ public function calculateDependencies() { * @return string[] * An array of format options. Both key and value are the same. */ - protected function getFormatOptions() { + public function getFormatOptions() { $formats = array_keys($this->formatProviders); return array_combine($formats, $formats); } diff --git a/core/modules/rest/src/Tests/Views/StyleSerializerTest.php b/core/modules/rest/src/Tests/Views/StyleSerializerTest.php index 83a154848e..6fe6c17a59 100644 --- a/core/modules/rest/src/Tests/Views/StyleSerializerTest.php +++ b/core/modules/rest/src/Tests/Views/StyleSerializerTest.php @@ -89,6 +89,9 @@ public function testRestViewsAuthentication() { $url = $this->buildUrl('test/serialize/auth_with_perm'); $response = \Drupal::httpClient()->get($url, [ 'auth' => [$this->adminUser->getUsername(), $this->adminUser->pass_raw], + 'query' => [ + '_format' => 'json', + ], ]); // Ensure that any changes to variables in the other thread are picked up. @@ -178,7 +181,7 @@ public function testSerializerResponses() { $this->assertIdentical($actual_json, $expected, 'The expected HAL output was found.'); $this->assertCacheTags($expected_cache_tags); - // Change the default format to xml. + // Change the format to xml. $view->setDisplay('rest_export_1'); $view->getDisplay()->setOption('style', [ 'type' => 'serializer', @@ -191,7 +194,7 @@ public function testSerializerResponses() { ]); $view->save(); $expected = $serializer->serialize($entities, 'xml'); - $actual_xml = $this->drupalGet('test/serialize/entity'); + $actual_xml = $this->drupalGetWithFormat('test/serialize/entity', 'xml'); $this->assertIdentical($actual_xml, $expected, 'The expected XML output was found.'); $this->assertCacheContexts(['languages:language_interface', 'theme', 'entity_test_view_grants', 'request_format']); @@ -364,14 +367,19 @@ public function testResponseFormatConfiguration() { // Should return a 200. // @todo This should be fixed when we have better content negotiation. - $this->drupalGet('test/serialize/field'); + $this->drupalGetWithFormat('test/serialize/field', 'json'); $this->assertHeader('content-type', 'application/json'); $this->assertResponse(200, 'A 200 response was returned when any format was requested.'); - // Should return a 200. Emulates a sample Firefox header. - $this->drupalGet('test/serialize/field', [], ['Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8']); - $this->assertHeader('content-type', 'application/json'); - $this->assertResponse(200, 'A 200 response was returned when a browser accept header was requested.'); + // Should return a 406. Emulates a sample Firefox header. + $this->drupalGet('test/serialize/field', array(), array('Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8')); + $this->assertHeader('content-type', 'text/html; charset=UTF-8'); + $this->assertResponse(406, 'A 406 response was returned when a browser accept header was requested.'); + + // Should return a 406. + $this->drupalGetWithFormat('test/serialize/field', 'html'); + $this->assertHeader('content-type', 'text/html; charset=UTF-8'); + $this->assertResponse(406, 'A 406 response was returned when HTML was requested.'); // Should return a 200. $this->drupalGetWithFormat('test/serialize/field', 'json'); @@ -385,14 +393,8 @@ public function testResponseFormatConfiguration() { $this->assertResponse(200, 'A 200 response was returned when XML was requested'); $headers = $this->drupalGetHeaders(); $this->assertTrue(strpos($headers['content-type'], 'text/xml') !== FALSE, 'The header Content-type is correct.'); - // Should return a 406. - $this->drupalGetWithFormat('test/serialize/field', 'html'); - // We want to show the first format by default, see - // \Drupal\rest\Plugin\views\style\Serializer::render. - $this->assertHeader('content-type', 'application/json'); - $this->assertResponse(200, 'A 200 response was returned when HTML was requested.'); - // Now configure now format, so all of them should be allowed. + // Now configure no format, so all of them should be allowed. $this->drupalPostForm($style_options, ['style_options[formats][json]' => '0', 'style_options[formats][xml]' => '0'], t('Apply')); // Should return a 200. @@ -403,12 +405,10 @@ public function testResponseFormatConfiguration() { $this->drupalGetWithFormat('test/serialize/field', 'xml'); $this->assertHeader('content-type', 'text/xml; charset=UTF-8'); $this->assertResponse(200, 'A 200 response was returned when XML was requested'); - // Should return a 200. + // Should return a 406 for HTML. $this->drupalGetWithFormat('test/serialize/field', 'html'); - // We want to show the first format by default, see - // \Drupal\rest\Plugin\views\style\Serializer::render. - $this->assertHeader('content-type', 'application/json'); - $this->assertResponse(200, 'A 200 response was returned when HTML was requested.'); + $this->assertHeader('content-type', 'text/html; charset=UTF-8'); + $this->assertResponse(406, 'A 406 response was returned when HTML was requested.'); } /** @@ -606,7 +606,7 @@ public function testSerializerViewsUI() { $this->assertResponse(200); // Check if we receive the expected result. $result = $this->xpath('//div[@id="views-live-preview"]/pre'); - $this->assertIdentical($this->drupalGet('test/serialize/field'), (string) $result[0], 'The expected JSON preview output was found.'); + $this->assertIdentical($this->drupalGetWithFormat('test/serialize/field', 'json'), (string) $result[0], 'The expected JSON preview output was found.'); } /** diff --git a/core/modules/views/src/Plugin/views/display/Page.php b/core/modules/views/src/Plugin/views/display/Page.php index aca0683f3c..baf1434449 100644 --- a/core/modules/views/src/Plugin/views/display/Page.php +++ b/core/modules/views/src/Plugin/views/display/Page.php @@ -85,6 +85,18 @@ public static function create(ContainerInterface $container, array $configuratio } /** + * {@inheritdoc} + */ + protected function getRoute($view_id, $display_id) { + $route = parent::getRoute($view_id, $display_id); + + // Explicitly set HTML as the format for Page displays. + $route->setRequirement('_format', 'html'); + + return $route; + } + + /** * Sets the current page views render array. * * @param array $element diff --git a/core/modules/views_ui/src/ViewEditForm.php b/core/modules/views_ui/src/ViewEditForm.php index 67d1229555..50fa3c03dd 100644 --- a/core/modules/views_ui/src/ViewEditForm.php +++ b/core/modules/views_ui/src/ViewEditForm.php @@ -15,6 +15,7 @@ use Drupal\views\Views; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpKernel\Exception\NotAcceptableHttpException; /** * Form controller for the Views edit form. @@ -414,15 +415,25 @@ public function getDisplayDetails($view, $display) { // path. elseif ($view->status() && $view->getExecutable()->displayHandlers->get($display['id'])->hasPath()) { $path = $view->getExecutable()->displayHandlers->get($display['id'])->getPath(); + if ($path && (strpos($path, '%') === FALSE)) { - if (!parse_url($path, PHP_URL_SCHEME)) { - // @todo Views should expect and store a leading /. See: - // https://www.drupal.org/node/2423913 - $url = Url::fromUserInput('/' . ltrim($path, '/')); + // Wrap this in a try/catch as trying to generate links to some + // routes may throw a NotAcceptableHttpException if they do not + // respond to HTML, such as RESTExports. + try { + if (!parse_url($path, PHP_URL_SCHEME)) { + // @todo Views should expect and store a leading /. See: + // https://www.drupal.org/node/2423913 + $url = Url::fromUserInput('/' . ltrim($path, '/')); + } + else { + $url = Url::fromUri("base:$path"); + } } - else { - $url = Url::fromUri("base:$path"); + catch (NotAcceptableHttpException $e) { + $url = '/' . $path; } + $build['top']['actions']['path'] = [ '#type' => 'link', '#title' => $this->t('View @display_title', ['@display_title' => $display_title]), diff --git a/core/modules/views_ui/src/ViewListBuilder.php b/core/modules/views_ui/src/ViewListBuilder.php index 604426cab0..c6e3ef3179 100644 --- a/core/modules/views_ui/src/ViewListBuilder.php +++ b/core/modules/views_ui/src/ViewListBuilder.php @@ -9,6 +9,7 @@ use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Url; use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpKernel\Exception\NotAcceptableHttpException; /** * Defines a class to build a listing of view entities. @@ -257,7 +258,7 @@ protected function getDisplaysList(EntityInterface $view) { if ($view->status() && strpos($path, '%') === FALSE) { // @todo Views should expect and store a leading /. See: // https://www.drupal.org/node/2423913 - $rendered_path = \Drupal::l('/' . $path, Url::fromUserInput('/' . $path)); + $rendered_path = \Drupal::l('/' . $path, Url::fromRoute($display->getRouteName())); } else { $rendered_path = '/' . $path;