diff --git a/core/core.services.yml b/core/core.services.yml index c818c8a080107e62eb6f3e39c6c9d20edd721dd2..6310c211b55e871650442ad338795517ffe6b24d 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1635,3 +1635,7 @@ services: arguments: ['@current_user', '@path.current', '@path.matcher', '@language_manager'] tags: - { name: event_subscriber } + response_filter.rss.relative_url: + class: Drupal\Core\EventSubscriber\RssResponseRelativeUrlFilter + tags: + - { name: event_subscriber } diff --git a/core/lib/Drupal/Component/Utility/Html.php b/core/lib/Drupal/Component/Utility/Html.php index 758a05337207f904ad5bbdd512d977bd27241911..5456cfaaf32f421ce1f69af5ccc63d83347e056d 100644 --- a/core/lib/Drupal/Component/Utility/Html.php +++ b/core/lib/Drupal/Component/Utility/Html.php @@ -36,6 +36,25 @@ class Html { */ protected static $isAjax = FALSE; + /** + * All attributes that may contain URIs. + * + * - The attributes 'code' and 'codebase' are omitted, because they only exist + * for the tag. The time of Java applets has passed. + * - The attribute 'icon' is omitted, because no browser implements the + * tag anymore. + * See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/command. + * - The 'manifest' attribute is omitted because it only exists for the + * tag. That tag only makes sense in a HTML-served-as-HTML context, in which + * case relative URLs are guaranteed to work. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes + * @see https://stackoverflow.com/questions/2725156/complete-list-of-html-tag-attributes-which-have-a-url-value + * + * @var string[] + */ + protected static $uriAttributes = ['href', 'poster', 'src', 'cite', 'data', 'action', 'formaction', 'srcset', 'about']; + /** * Prepares a string for use as a valid class name. * @@ -402,4 +421,61 @@ public static function escape($text) { return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } + /** + * Converts all root-relative URLs to absolute URLs. + * + * Does not change any existing protocol-relative or absolute URLs. Does not + * change other relative URLs because they would result in different absolute + * URLs depending on the current path. For example: when the same content + * containing such a relative URL (for example 'image.png'), is served from + * its canonical URL (for example 'http://example.com/some-article') or from + * a listing or feed (for example 'http://example.com/all-articles') their + * "current path" differs, resulting in different absolute URLs: + * 'http://example.com/some-article/image.png' versus + * 'http://example.com/all-articles/image.png'. Only one can be correct. + * Therefore relative URLs that are not root-relative cannot be safely + * transformed and should generally be avoided. + * + * Necessary for HTML that is served outside of a website, for example, RSS + * and e-mail. + * + * @param string $html + * The partial (X)HTML snippet to load. Invalid markup will be corrected on + * import. + * @param string $scheme_and_host + * The root URL, which has a URI scheme, host and optional port. + * + * @return string + * The updated (X)HTML snippet. + */ + public static function transformRootRelativeUrlsToAbsolute($html, $scheme_and_host) { + assert('empty(array_diff(array_keys(parse_url($scheme_and_host)), ["scheme", "host", "port"]))', '$scheme_and_host contains scheme, host and port at most.'); + assert('isset(parse_url($scheme_and_host)["scheme"])', '$scheme_and_host is absolute and hence has a scheme.'); + assert('isset(parse_url($scheme_and_host)["host"])', '$base_url is absolute and hence has a host.'); + + $html_dom = Html::load($html); + $xpath = new \DOMXpath($html_dom); + + // Update all root-relative URLs to absolute URLs in the given HTML. + foreach (static::$uriAttributes as $attr) { + foreach ($xpath->query("//*[starts-with(@$attr, '/') and not(starts-with(@$attr, '//'))]") as $node) { + $node->setAttribute($attr, $scheme_and_host . $node->getAttribute($attr)); + } + foreach ($xpath->query("//*[@srcset]") as $node) { + // @see https://html.spec.whatwg.org/multipage/embedded-content.html#attr-img-srcset + // @see https://html.spec.whatwg.org/multipage/embedded-content.html#image-candidate-string + $image_candidate_strings = explode(',', $node->getAttribute('srcset')); + $image_candidate_strings = array_map('trim', $image_candidate_strings); + for ($i = 0; $i < count($image_candidate_strings); $i++) { + $image_candidate_string = $image_candidate_strings[$i]; + if ($image_candidate_string[0] === '/' && $image_candidate_string[1] !== '/') { + $image_candidate_strings[$i] = $scheme_and_host . $image_candidate_string; + } + } + $node->setAttribute('srcset', implode(', ', $image_candidate_strings)); + } + } + return Html::serialize($html_dom); + } + } diff --git a/core/lib/Drupal/Core/EventSubscriber/RssResponseRelativeUrlFilter.php b/core/lib/Drupal/Core/EventSubscriber/RssResponseRelativeUrlFilter.php new file mode 100644 index 0000000000000000000000000000000000000000..6c05451cf91540a1b73c7a3b1c91ed50627aa49f --- /dev/null +++ b/core/lib/Drupal/Core/EventSubscriber/RssResponseRelativeUrlFilter.php @@ -0,0 +1,72 @@ +getResponse()->headers->get('Content-Type'), 'application/rss+xml') === FALSE) { + return; + } + + $response = $event->getResponse(); + $response->setContent($this->transformRootRelativeUrlsToAbsolute($response->getContent(), $event->getRequest())); + } + + /** + * Converts all root-relative URLs to absolute URLs in RSS markup. + * + * Does not change any existing protocol-relative or absolute URLs. + * + * @param string $rss_markup + * The RSS markup to update. + * @param \Symfony\Component\HttpFoundation\Request $request + * The current request. + * + * @return string + * The updated RSS markup. + */ + protected function transformRootRelativeUrlsToAbsolute($rss_markup, Request $request) { + $rss_dom = new \DOMDocument(); + $rss_dom->loadXML($rss_markup); + + // Invoke Html::transformRootRelativeUrlsToAbsolute() on all HTML content + // embedded in this RSS feed. + foreach ($rss_dom->getElementsByTagName('description') as $node) { + $html_markup = $node->nodeValue; + if (!empty($html_markup)) { + $node->nodeValue = Html::transformRootRelativeUrlsToAbsolute($html_markup, $request->getSchemeAndHttpHost()); + } + } + + return $rss_dom->saveXML(); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + // Should run after any other response subscriber that modifies the markup. + // @see \Drupal\Core\EventSubscriber\ActiveLinkResponseFilter + $events[KernelEvents::RESPONSE][] = ['onResponse', -512]; + + return $events; + } + +} diff --git a/core/modules/file/src/Tests/FileFieldRSSContentTest.php b/core/modules/file/src/Tests/FileFieldRSSContentTest.php index 665649aae9ff046f944b1e22419fe8a0cb2f20e4..0422514940c8c95a36fa7083dc602dfc4da9140c 100644 --- a/core/modules/file/src/Tests/FileFieldRSSContentTest.php +++ b/core/modules/file/src/Tests/FileFieldRSSContentTest.php @@ -56,13 +56,13 @@ function testFileFieldRSSContent() { // Check that the RSS enclosure appears in the RSS feed. $this->drupalGet('rss.xml'); $uploaded_filename = str_replace('public://', '', $node_file->getFileUri()); - $test_element = sprintf( - '', + $selector = sprintf( + 'enclosure[url="%s"][length="%s"][type="%s"]', file_create_url("public://$uploaded_filename", array('absolute' => TRUE)), $node_file->getSize(), $node_file->getMimeType() ); - $this->assertRaw($test_element, 'File field RSS enclosure is displayed when viewing the RSS feed.'); + $this->assertTrue(!empty($this->cssSelect($selector)), 'File field RSS enclosure is displayed when viewing the RSS feed.'); } } diff --git a/core/modules/node/src/Tests/NodeRSSContentTest.php b/core/modules/node/src/Tests/NodeRSSContentTest.php index 27870bd9564effaa4189bbbff81ef854f7a1bf1b..d1ce24067ca2fe3ba3174e77058178e80a97e9a8 100644 --- a/core/modules/node/src/Tests/NodeRSSContentTest.php +++ b/core/modules/node/src/Tests/NodeRSSContentTest.php @@ -2,6 +2,8 @@ namespace Drupal\node\Tests; +use Drupal\filter\Entity\FilterFormat; + /** * Ensures that data added to nodes by other modules appears in RSS feeds. * @@ -60,4 +62,47 @@ function testNodeRSSContent() { $this->assertNoText($rss_only_content, 'Node content designed for RSS does not appear when viewing node.'); } + /** + * Tests relative, root-relative, protocol-relative and absolute URLs. + */ + public function testUrlHandling() { + // Only the plain_text text format is available by default, which escapes + // all HTML. + FilterFormat::create([ + 'format' => 'full_html', + 'name' => 'Full HTML', + 'filters' => [], + ])->save(); + + $defaults = [ + 'type' => 'article', + 'promote' => 1, + ]; + $this->drupalCreateNode($defaults + [ + 'body' => [ + 'value' => '

Root-relative URL

', + 'format' => 'full_html', + ], + ]); + $protocol_relative_url = substr(file_create_url('public://protocol-relative'), strlen(\Drupal::request()->getScheme() . ':')); + $this->drupalCreateNode($defaults + [ + 'body' => [ + 'value' => '

Protocol-relative URL

', + 'format' => 'full_html', + ], + ]); + $absolute_url = file_create_url('public://absolute'); + $this->drupalCreateNode($defaults + [ + 'body' => [ + 'value' => '

Absolute URL

', + 'format' => 'full_html', + ], + ]); + + $this->drupalGet('rss.xml'); + $this->assertRaw(file_create_url('public://root-relative'), 'Root-relative URL is transformed to absolute.'); + $this->assertRaw($protocol_relative_url, 'Protocol-relative URL is left untouched.'); + $this->assertRaw($absolute_url, 'Absolute URL is left untouched.'); + } + } diff --git a/core/modules/taxonomy/src/Tests/RssTest.php b/core/modules/taxonomy/src/Tests/RssTest.php index 10e47afe0079e77d5c87587f5684d48b8cd3fd55..effd4dd97ca06f51731ee9a2ec4e1e71e5a3b885 100644 --- a/core/modules/taxonomy/src/Tests/RssTest.php +++ b/core/modules/taxonomy/src/Tests/RssTest.php @@ -105,7 +105,7 @@ function testTaxonomyRss() { // Test that the feed page exists for the term. $this->drupalGet("taxonomy/term/{$term1->id()}/feed"); - $this->assertRaw('assertTrue(!empty($this->cssSelect('rss[version="2.0"]')), "Feed page is RSS."); // Check that the "Exception value" is disabled by default. $this->drupalGet('taxonomy/term/all/feed'); diff --git a/core/modules/views/src/Tests/Wizard/BasicTest.php b/core/modules/views/src/Tests/Wizard/BasicTest.php index a8eedf2cbcdc83341b47ee46f4416818a409abe7..1e2d051851ab0744732f9023bdf19d0ec78555af 100644 --- a/core/modules/views/src/Tests/Wizard/BasicTest.php +++ b/core/modules/views/src/Tests/Wizard/BasicTest.php @@ -80,7 +80,7 @@ function testViewsWizardAndListing() { $elements = $this->cssSelect('link[href="' . Url::fromRoute('view.' . $view2['id'] . '.feed_1', [], ['absolute' => TRUE])->toString() . '"]'); $this->assertEqual(count($elements), 1, 'Feed found.'); $this->drupalGet($view2['page[feed_properties][path]']); - $this->assertRaw('assertTrue(!empty($this->cssSelect('rss[version="2.0"]'))); // The feed should have the same title and nodes as the page. $this->assertText($view2['page[title]']); $this->assertRaw($node1->url('canonical', ['absolute' => TRUE])); diff --git a/core/tests/Drupal/Tests/Component/Utility/HtmlTest.php b/core/tests/Drupal/Tests/Component/Utility/HtmlTest.php index 88e0240be6423a1880f3a98bb617eaa0a1a9cf14..6a106c7b9e4a900dd2d39285949dd0ed30874ed0 100644 --- a/core/tests/Drupal/Tests/Component/Utility/HtmlTest.php +++ b/core/tests/Drupal/Tests/Component/Utility/HtmlTest.php @@ -321,4 +321,72 @@ public function testSerialize() { $this->assertSame('', $result); } + /** + * @covers ::transformRootRelativeUrlsToAbsolute + * @dataProvider providerTestTransformRootRelativeUrlsToAbsolute + */ + public function testTransformRootRelativeUrlsToAbsolute($html, $scheme_and_host, $expected_html) { + $this->assertSame($expected_html ?: $html, Html::transformRootRelativeUrlsToAbsolute($html, $scheme_and_host)); + } + + /** + * @covers ::transformRootRelativeUrlsToAbsolute + * @dataProvider providerTestTransformRootRelativeUrlsToAbsoluteAssertion + * @expectedException \AssertionError + */ + public function testTransformRootRelativeUrlsToAbsoluteAssertion($scheme_and_host) { + Html::transformRootRelativeUrlsToAbsolute('', $scheme_and_host); + } + + /** + * Provides test data for testTransformRootRelativeUrlsToAbsolute(). + * + * @return array + * Test data. + */ + public function providerTestTransformRootRelativeUrlsToAbsolute() { + $data = []; + + // One random tag name. + $tag_name = strtolower($this->randomMachineName()); + + // A site installed either in the root of a domain or a subdirectory. + $base_paths = ['/', '/subdir/' . $this->randomMachineName() . '/']; + + foreach ($base_paths as $base_path) { + // The only attribute that has more than just a URL as its value, is + // 'srcset', so special-case it. + $data += [ + "$tag_name, srcset, $base_path: root-relative" => ["<$tag_name srcset=\"http://example.com{$base_path}already-absolute 200w, {$base_path}root-relative 300w\">root-relative test", 'http://example.com', "<$tag_name srcset=\"http://example.com{$base_path}already-absolute 200w, http://example.com{$base_path}root-relative 300w\">root-relative test"], + "$tag_name, srcset, $base_path: protocol-relative" => ["<$tag_name srcset=\"http://example.com{$base_path}already-absolute 200w, //example.com{$base_path}protocol-relative 300w\">protocol-relative test", 'http://example.com', FALSE], + "$tag_name, srcset, $base_path: absolute" => ["<$tag_name srcset=\"http://example.com{$base_path}already-absolute 200w, http://example.com{$base_path}absolute 300w\">absolute test", 'http://example.com', FALSE], + ]; + + foreach (['href', 'poster', 'src', 'cite', 'data', 'action', 'formaction', 'about'] as $attribute) { + $data += [ + "$tag_name, $attribute, $base_path: root-relative" => ["<$tag_name $attribute=\"{$base_path}root-relative\">root-relative test", 'http://example.com', "<$tag_name $attribute=\"http://example.com{$base_path}root-relative\">root-relative test"], + "$tag_name, $attribute, $base_path: protocol-relative" => ["<$tag_name $attribute=\"//example.com{$base_path}protocol-relative\">protocol-relative test", 'http://example.com', FALSE], + "$tag_name, $attribute, $base_path: absolute" => ["<$tag_name $attribute=\"http://example.com{$base_path}absolute\">absolute test", 'http://example.com', FALSE], + ]; + } + } + + return $data; + } + + /** + * Provides test data for testTransformRootRelativeUrlsToAbsoluteAssertion(). + * + * @return array + * Test data. + */ + public function providerTestTransformRootRelativeUrlsToAbsoluteAssertion() { + return [ + 'only relative path' => ['llama'], + 'only root-relative path' => ['/llama'], + 'host and path' => ['example.com/llama'], + 'scheme, host and path' => ['http://example.com/llama'], + ]; + } + }