diff --git a/core/core.libraries.yml b/core/core.libraries.yml index ce12db5..b791de2 100644 --- a/core/core.libraries.yml +++ b/core/core.libraries.yml @@ -77,6 +77,15 @@ drupal.active-link: - core/drupalSettings - core/classList +drupal.current-path-destination-link: + version: VERSION + js: + misc/current-path-destination-link.js: {} + dependencies: + - core/drupal + - core/drupalSettings + - core/jquery + drupal.ajax: version: VERSION js: diff --git a/core/core.services.yml b/core/core.services.yml index 5b0de75..f2a2990 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1657,3 +1657,8 @@ services: class: Drupal\Core\EventSubscriber\RssResponseRelativeUrlFilter tags: - { name: event_subscriber } + response_filter.current_path_destination_link: + class: Drupal\Core\EventSubscriber\CurrentPathDestinationLinkResponseFilter + arguments: ['@current_user', '@path.current', '@request_stack'] + tags: + - { name: event_subscriber } diff --git a/core/lib/Drupal/Core/EventSubscriber/CurrentPathDestinationLinkResponseFilter.php b/core/lib/Drupal/Core/EventSubscriber/CurrentPathDestinationLinkResponseFilter.php new file mode 100644 index 0000000..f24dfe8 --- /dev/null +++ b/core/lib/Drupal/Core/EventSubscriber/CurrentPathDestinationLinkResponseFilter.php @@ -0,0 +1,177 @@ +currentUser = $current_user; + $this->currentPath = $current_path; + $this->requestStack = $request_stack; + } + + /** + * Adds "destination" query string pointing to the current path. + * + * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event + * The response event. + */ + public function onResponse(FilterResponseEvent $event) { + // Only care about HTML responses. + if (stripos($event->getResponse()->headers->get('Content-Type'), 'text/html') === FALSE) { + return; + } + + // For authenticated users, the 'destination' query string is set in + // JavaScript. + // @see system_page_attachments() + if ($this->currentUser->isAuthenticated()) { + return; + } + + $master_request = $this->requestStack->getMasterRequest(); + + $response = $event->getResponse(); + $response->setContent(static::setCurrentPathAsDestination( + $response->getContent(), + ltrim($master_request->getPathInfo(), '/'), + $master_request->getQueryString() + )); + } + + /** + * Adds "destination" query string pointing to the current path. + * + * This is a PHP implementation of the drupal.current-path-destination-link + * JavaScript library. + * + * @param string $html_markup. + * The HTML markup to update. + * @param string $current_path + * The system path of the currently active page. + * @param string $current_query + * The query string for the currently active page. + * + * @return string + * The updated HTML markup. + * + * @todo Once a future version of PHP supports parsing HTML5 properly + * (i.e. doesn't fail on + * https://www.drupal.org/comment/7938201#comment-7938201) then we can get + * rid of this manual parsing and use DOMDocument instead. + */ + public static function setCurrentPathAsDestination($html_markup, $current_path, $current_query) { + $destination_value = $current_path; + if (!empty($current_query)) { + $destination_value .= '?' . $current_query; + } + $destination = 'destination=' . UrlHelper::encodePath($destination_value); + + $offset = 0; + while (($pos_match = strpos($html_markup, 'data-current-path-destination', $offset)) !== FALSE) { + // Find beginning and ending of opening tag. + $pos_tag_start = NULL; + for ($i = $pos_match; $pos_tag_start === NULL && $i > 0; $i--) { + if ($html_markup[$i] === '<') { + $pos_tag_start = $i; + } + } + $pos_tag_end = NULL; + for ($i = $pos_match; $pos_tag_end === NULL && $i < strlen($html_markup); $i++) { + if ($html_markup[$i] === '>') { + $pos_tag_end = $i; + } + } + + // Get the HTML: this will be the opening part of a single tag, e.g.: + // + $tag = substr($html_markup, $pos_tag_start, $pos_tag_end - $pos_tag_start + 1); + + // Parse it into a DOMDocument so we can reliably read and modify + // attributes. + $dom = new \DOMDocument(); + @$dom->loadHTML('' . $tag . ''); + $node = $dom->getElementsByTagName('body')->item(0)->firstChild; + + $url = $node->getAttribute('href'); + $glue = (strpos($url, '?') === FALSE) ? '?' : '&'; + $node->setAttribute('href', $url . $glue . $destination); + $node->removeAttribute('data-current-path-destination'); + + // Get the updated tag. + $updated_tag = $dom->saveXML($node, LIBXML_NOEMPTYTAG); + // saveXML() added a closing tag, remove it. + $updated_tag = substr($updated_tag, 0, strrpos($updated_tag, '<')); + + $html_markup = str_replace($tag, $updated_tag, $html_markup); + + // Ensure we only search the remaining HTML. + $offset = $pos_tag_end - strlen($tag) + strlen($updated_tag); + } + + return $html_markup; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + // Should run after any other response subscriber that modifies the markup. + $events[KernelEvents::RESPONSE][] = ['onResponse', -512]; + + return $events; + } + +} diff --git a/core/misc/current-path-destination-link.js b/core/misc/current-path-destination-link.js new file mode 100644 index 0000000..32c7dd1 --- /dev/null +++ b/core/misc/current-path-destination-link.js @@ -0,0 +1,32 @@ +/** + * @file + * Attaches behaviors for Drupal's current path destination link query string. + */ + +(function (Drupal, drupalSettings, $) { + + "use strict"; + + /** + * Adds "destination" query string pointing to the current path. + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.currentPathDestinationLink = { + attach: function (context) { + var destinationValue = drupalSettings.path.currentPath; + if (drupalSettings.path.currentQuery) { + destinationValue += '?' + $.param(drupalSettings.path.currentQuery); + } + var destination = 'destination=' + Drupal.encodePath(destinationValue); + var links = context.querySelectorAll('[data-current-path-destination]'); + for (var i = 0; i < links.length; i++) { + var link = links[i]; + var url = link.getAttribute('href'); + var glue = (url.indexOf('?') === -1) ? '?' : '&'; + link.setAttribute('href', url + glue + destination); + } + } + }; + +})(Drupal, drupalSettings, jQuery); diff --git a/core/modules/block/src/Tests/Views/DisplayBlockTest.php b/core/modules/block/src/Tests/Views/DisplayBlockTest.php index 368af68..1576191 100644 --- a/core/modules/block/src/Tests/Views/DisplayBlockTest.php +++ b/core/modules/block/src/Tests/Views/DisplayBlockTest.php @@ -366,8 +366,8 @@ public function testBlockContextualLinks() { $response = $this->drupalPostWithFormat('contextual/render', 'json', $post, array('query' => array('destination' => 'test-page'))); $this->assertResponse(200); $json = Json::decode($response); - $this->assertIdentical($json[$id], ''); - $this->assertIdentical($json[$cached_id], ''); + $this->assertIdentical($json[$id], ''); + $this->assertIdentical($json[$cached_id], ''); } } diff --git a/core/modules/contextual/contextual.libraries.yml b/core/modules/contextual/contextual.libraries.yml index 51281af..42edc7c 100644 --- a/core/modules/contextual/contextual.libraries.yml +++ b/core/modules/contextual/contextual.libraries.yml @@ -24,6 +24,7 @@ drupal.contextual-links: - core/backbone - core/modernizr - core/jquery.once + - core/drupal.current-path-destination-link drupal.contextual-toolbar: version: VERSION diff --git a/core/modules/contextual/js/contextual.js b/core/modules/contextual/js/contextual.js index 7f93a15..0cc7914 100644 --- a/core/modules/contextual/js/contextual.js +++ b/core/modules/contextual/js/contextual.js @@ -54,13 +54,9 @@ // Ensure a trigger element exists before the actual contextual links. .prepend(Drupal.theme('contextualTrigger')); - // Set the destination parameter on each of the contextual links. - var destination = 'destination=' + Drupal.encodePath(drupalSettings.path.currentPath); - $contextual.find('.contextual-links a').each(function () { - var url = this.getAttribute('href'); - var glue = (url.indexOf('?') === -1) ? '?' : '&'; - this.setAttribute('href', url + glue + destination); - }); + // Call Drupal.behaviors.currentPathDestinationLink.attach directly to + // set all destination parameters. + Drupal.behaviors.currentPathDestinationLink.attach($contextual.get(0)); // Create a model and the appropriate views. var model = new contextual.StateModel({ diff --git a/core/modules/contextual/src/Element/ContextualLinks.php b/core/modules/contextual/src/Element/ContextualLinks.php index 8f67023..74f5c5a 100644 --- a/core/modules/contextual/src/Element/ContextualLinks.php +++ b/core/modules/contextual/src/Element/ContextualLinks.php @@ -79,6 +79,9 @@ public static function preRenderLinks(array $element) { $links[$class] = array( 'title' => $item['title'], 'url' => Url::fromRoute(isset($item['route_name']) ? $item['route_name'] : '', isset($item['route_parameters']) ? $item['route_parameters'] : []), + 'attributes' => array( + 'data-current-path-destination' => TRUE, + ) ); } $element['#links'] = $links; diff --git a/core/modules/contextual/src/Tests/ContextualDynamicContextTest.php b/core/modules/contextual/src/Tests/ContextualDynamicContextTest.php index e5d2c85..0aad0e2 100644 --- a/core/modules/contextual/src/Tests/ContextualDynamicContextTest.php +++ b/core/modules/contextual/src/Tests/ContextualDynamicContextTest.php @@ -95,9 +95,9 @@ function testDifferentPermissions() { $response = $this->renderContextualLinks($ids, 'node'); $this->assertResponse(200); $json = Json::decode($response); - $this->assertIdentical($json[$ids[0]], ''); + $this->assertIdentical($json[$ids[0]], ''); $this->assertIdentical($json[$ids[1]], ''); - $this->assertIdentical($json[$ids[2]], ''); + $this->assertIdentical($json[$ids[2]], ''); $this->assertIdentical($json[$ids[3]], ''); // Verify that link language is properly handled. diff --git a/core/modules/menu_ui/src/Tests/MenuTest.php b/core/modules/menu_ui/src/Tests/MenuTest.php index e9464ab..0059c4b 100644 --- a/core/modules/menu_ui/src/Tests/MenuTest.php +++ b/core/modules/menu_ui/src/Tests/MenuTest.php @@ -579,7 +579,7 @@ public function testBlockContextualLinks() { $response = $this->drupalPost('contextual/render', 'application/json', $post, array('query' => array('destination' => 'test-page'))); $this->assertResponse(200); $json = Json::decode($response); - $this->assertIdentical($json[$id], ''); + $this->assertIdentical($json[$id], ''); } /** diff --git a/core/modules/system/system.module b/core/modules/system/system.module index 3c858f9..5c77a90 100644 --- a/core/modules/system/system.module +++ b/core/modules/system/system.module @@ -620,6 +620,15 @@ function system_page_attachments(array &$page) { if (\Drupal::currentUser()->isAuthenticated()) { $page['#attached']['library'][] = 'core/drupal.active-link'; } + + // Handle setting the "destination" query string on links by: + // - loading the current-path-destination-link library if the current user is + // authenticated; + // - applying a response filter if the current user is anonymous. + // @see \Drupal\Core\EventSubscriber\CurrentPathDestinationLinkResponseFilter + if (\Drupal::currentUser()->isAuthenticated()) { + $page['#attached']['library'][] = 'core/drupal.current-path-destination-link'; + } } /** diff --git a/core/modules/user/src/Plugin/Menu/LoginLogoutMenuLink.php b/core/modules/user/src/Plugin/Menu/LoginLogoutMenuLink.php index 55821e0..e20173f 100644 --- a/core/modules/user/src/Plugin/Menu/LoginLogoutMenuLink.php +++ b/core/modules/user/src/Plugin/Menu/LoginLogoutMenuLink.php @@ -79,6 +79,22 @@ public function getRouteName() { /** * {@inheritdoc} */ + public function getOptions() { + $options = parent::getOptions(); + + // Ensure that after logging in, the user is redirected to the current page. + // @see system_page_attachments() + // @see \Drupal\Core\EventSubscriber\CurrentPathDestinationLinkResponseFilter + if (!$this->currentUser->isAuthenticated()) { + $options['attributes']['data-current-path-destination'] = TRUE; + } + + return $options; + } + + /** + * {@inheritdoc} + */ public function getCacheContexts() { return ['user.roles:authenticated']; } diff --git a/core/modules/user/src/Tests/UserLoginTest.php b/core/modules/user/src/Tests/UserLoginTest.php index af00c74..8bfc617 100644 --- a/core/modules/user/src/Tests/UserLoginTest.php +++ b/core/modules/user/src/Tests/UserLoginTest.php @@ -2,6 +2,7 @@ namespace Drupal\user\Tests; +use Drupal\Core\Url; use Drupal\simpletest\WebTestBase; use Drupal\user\Entity\User; diff --git a/core/modules/views_ui/src/Tests/DisplayTest.php b/core/modules/views_ui/src/Tests/DisplayTest.php index 7f5f125..8259276 100644 --- a/core/modules/views_ui/src/Tests/DisplayTest.php +++ b/core/modules/views_ui/src/Tests/DisplayTest.php @@ -208,7 +208,7 @@ public function testPageContextualLinks() { $response = $this->drupalPostWithFormat('contextual/render', 'json', $post, array('query' => array('destination' => 'test-display'))); $this->assertResponse(200); $json = Json::decode($response); - $this->assertIdentical($json[$id], ''); + $this->assertIdentical($json[$id], ''); // When a "main content" is placed, we still find a contextual link // placeholder for editing just the view (not the main content block). diff --git a/core/profiles/standard/tests/src/Functional/StandardTest.php b/core/profiles/standard/tests/src/Functional/StandardTest.php index 6416368..ff55abf 100644 --- a/core/profiles/standard/tests/src/Functional/StandardTest.php +++ b/core/profiles/standard/tests/src/Functional/StandardTest.php @@ -197,6 +197,16 @@ function testStandard() { $this->drupalGet($url); $this->drupalGet($url); $this->assertEqual('HIT', $this->drupalGetHeader(DynamicPageCacheSubscriber::HEADER), 'User profile page is cached by Dynamic Page Cache.'); + + $this->drupalLogout(); + $this->drupalGet('admin'); + $this->assertResponse('403'); + $base_url = \Drupal::service('request_stack')->getMasterRequest()->getBaseUrl(); + $desination = 'admin'; + if ($base_url) { + $desination = $base_url . '/' . $desination; + } + $this->assertLinkByHref(Url::fromRoute('user.login', [], ['query' => ['destination' => $desination]])->toString()); } } diff --git a/core/tests/Drupal/Tests/Core/EventSubscriber/CurrentPathDestinationLinkResponseFilterTest.php b/core/tests/Drupal/Tests/Core/EventSubscriber/CurrentPathDestinationLinkResponseFilterTest.php new file mode 100644 index 0000000..82587db --- /dev/null +++ b/core/tests/Drupal/Tests/Core/EventSubscriber/CurrentPathDestinationLinkResponseFilterTest.php @@ -0,0 +1,132 @@ + + + +'; + $html = [ + // Simple HTML. + 0 => ['prefix' => '

', 'suffix' => '

'], + // Tricky HTML5 example that's unsupported by PHP <=5.4's DOMDocument: + // https://www.drupal.org/comment/7938201#comment-7938201. + 1 => ['prefix' => '

', 'suffix' => '

' . $edge_case_html5 . '
'], + // Multi-byte content *before* the HTML that needs the "destination" query + // string. + 2 => ['prefix' => '

αβγδεζηθικλμνξοσὠ

', 'suffix' => '

'], + ]; + $tags = [ + // Of course, it must work on anchors. + 'a', + // … but it should work, on *any* tag, really. + 'foo', + ]; + $contents = [ + // Regular content. + 'test', + // Mix of UTF-8 and HTML entities, both must be retained. + '☆ 3 × 4 = €12 and 4 × 3 = €12 ☆', + // Multi-byte content. + 'ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΣὨ', + ]; + $situations = [ + // Without a "data-current-path-destination" attribute present, nothing + // happens. + 0 => ['path' => 'foo/bar', 'attributes' => ['href' => '/some/path'], 'query' => ['llama' => 'awesome', 'kitten' => 'cool'], 'expected href' => '/some/path'], + // With a "data-current-path-destination" attribute present, the + // "destination" query string is added. + 1 => ['path' => 'foo/bar', 'attributes' => ['href' => '/some/path', 'data-current-path-destination' => TRUE], 'query' => [], 'expected href' => '/some/path?destination=foo/bar'], + // Same, but now with a query string. + 2 => ['path' => 'foo/bar', 'attributes' => ['href' => '/some/path', 'data-current-path-destination' => TRUE], 'query' => ['llama' => 'awesome', 'kitten' => 'cool'], 'expected href' => '/some/path?destination=foo/bar%3Fllama%3Dawesome%26kitten%3Dcool'], + // With a "data-current-path-destination" attribute present on a link that + // already has a "destination" query string, we just append it. We do not + // babysit broken code in CurrentPathDestinationLinkResponseFilter. + 3 => ['path' => 'foo/bar', 'attributes' => ['href' => '/some/path?destination=/already/there', 'data-current-path-destination' => TRUE], 'query' => [], 'expected href' => '/some/path?destination=/already/there&destination=foo/bar'], + ]; + + // Loop over the surrounding HTML variations. + $data = []; + for ($h = 0; $h < count($html); $h++) { + $html_prefix = $html[$h]['prefix']; + $html_suffix = $html[$h]['suffix']; + // Loop over the tag variations. + for ($t = 0; $t < count($tags); $t++) { + $tag = $tags[$t]; + // Loop over the tag contents variations. + for ($c = 0; $c < count($contents); $c++) { + $tag_content = $contents[$c]; + + $create_markup = function (Attribute $attributes) use ($html_prefix, $html_suffix, $tag, $tag_content) { + return $html_prefix . '<' . $tag . $attributes . '>' . $tag_content . '' . $html_suffix; + }; + + // Loop over the situations. + for ($s = 0; $s < count($situations); $s++) { + $situation = $situations[$s]; + + // Build the source markup. + $source_markup = $create_markup(new Attribute($situation['attributes'])); + + // Build the target markup. + $active_attributes = $situation['attributes']; + $active_attributes['href'] = $situation['expected href']; + unset($active_attributes['data-current-path-destination']); + $target_markup = $create_markup(new Attribute($active_attributes)); + + $data[] = [$source_markup, $situation['path'], $situation['query'], $target_markup]; + } + } + } + } + + return $data; + } + + /** + * Tests setCurrentPathAsDestination(). + * + * @param string $html_markup + * The original HTML markup. + * @param string $current_path + * The system path of the currently active page. + * @param string[] $current_query + * The query string for the currently active page. + * @param string $expected_html_markup + * The expected updated HTML markup. + * + * @dataProvider providerTestSetCurrentPathAsDestination + * @covers ::setCurrentPathAsDestination + */ + public function testSetCurrentPathAsDestination($html_markup, $current_path, array $current_query, $expected_html_markup) { + $this->assertSame($expected_html_markup, CurrentPathDestinationLinkResponseFilter::setCurrentPathAsDestination($html_markup, $current_path, UrlHelper::buildQuery($current_query))); + } + +}