diff --git a/core/core.services.yml b/core/core.services.yml index 91a2c2b27e136d95fd9e600b858038e4915b29af..7abab67b453c03d4e12678e9262f967cc7a574e8 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -489,6 +489,9 @@ services: arguments: ['@router.route_provider', '@path_processor_manager', '@route_processor_manager', '@config.factory', '@settings', '@logger.channel.default', '@request_stack'] calls: - [setContext, ['@?router.request_context']] + unrouted_url_assembler: + class: Drupal\Core\Utility\UnroutedUrlAssembler + arguments: ['@request_stack', '@config.factory' ] link_generator: class: Drupal\Core\Utility\LinkGenerator arguments: ['@url_generator', '@module_handler'] diff --git a/core/lib/Drupal/Core/Utility/UnroutedUrlAssembler.php b/core/lib/Drupal/Core/Utility/UnroutedUrlAssembler.php new file mode 100644 index 0000000000000000000000000000000000000000..05fb19f224398a5201b77dd84bca03382923d8ce --- /dev/null +++ b/core/lib/Drupal/Core/Utility/UnroutedUrlAssembler.php @@ -0,0 +1,152 @@ +<?php + +/** + * @file + * Contains Drupal\Core\Utility\UnroutedUrlAssembler. + */ + +namespace Drupal\Core\Utility; + +use Drupal\Component\Utility\UrlHelper; +use Drupal\Core\Config\ConfigFactoryInterface; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * Provides a way to build external or non Drupal local domain URLs. + * + * It takes into account configured safe HTTP protocols. + */ +class UnroutedUrlAssembler implements UnroutedUrlAssemblerInterface { + + /** + * A request stack object. + * + * @var \Symfony\Component\HttpFoundation\RequestStack + */ + protected $requestStack; + + /** + * Constructs a new unroutedUrlAssembler object. + * + * @param \Drupal\Core\Config\ConfigFactoryInterface $config + * The config factory. + * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack + * A request stack object. + */ + public function __construct(RequestStack $request_stack, ConfigFactoryInterface $config) { + $allowed_protocols = $config->get('system.filter')->get('protocols') ?: ['http', 'https']; + UrlHelper::setAllowedProtocols($allowed_protocols); + $this->requestStack = $request_stack; + } + + /** + * {@inheritdoc} + * + * This is a helper function that calls buildExternalUrl() or buildLocalUrl() + * based on a check of whether the path is a valid external URL. + */ + public function assemble($uri, array $options = []) { + // Note that UrlHelper::isExternal will return FALSE if the $uri has a + // disallowed protocol. This is later made safe since we always add at + // least a leading slash. + if (strpos($uri, 'base://') === 0) { + return $this->buildLocalUrl($uri, $options); + } + elseif (UrlHelper::isExternal($uri)) { + // UrlHelper::isExternal() only returns true for safe protocols. + return $this->buildExternalUrl($uri, $options); + } + throw new \InvalidArgumentException('You must use a valid URI scheme. Use base:// for a path e.g. to a Drupal file that needs the base path.'); + } + + /** + * {@inheritdoc} + */ + protected function buildExternalUrl($uri, array $options = []) { + $this->addOptionDefaults($options); + // Split off the fragment. + if (strpos($uri, '#') !== FALSE) { + list($uri, $old_fragment) = explode('#', $uri, 2); + // If $options contains no fragment, take it over from the path. + if (isset($old_fragment) && !$options['fragment']) { + $options['fragment'] = '#' . $old_fragment; + } + } + + if (isset($options['https'])) { + if ($options['https'] === TRUE) { + $uri = str_replace('http://', 'https://', $uri); + } + elseif ($options['https'] === FALSE) { + $uri = str_replace('https://', 'http://', $uri); + } + } + // Append the query. + if ($options['query']) { + $uri .= (strpos($uri, '?') !== FALSE ? '&' : '?') . UrlHelper::buildQuery($options['query']); + } + // Reassemble. + return $uri . $options['fragment']; + } + + /** + * {@inheritdoc} + */ + protected function buildLocalUrl($uri, array $options = []) { + $this->addOptionDefaults($options); + $request = $this->requestStack->getCurrentRequest(); + + // Remove the base:// scheme. + $uri = substr($uri, 7); + // Add any subdirectory where Drupal is installed. + $current_base_path = $request->getBasePath() . '/'; + + if ($options['absolute']) { + $current_base_url = $request->getSchemeAndHttpHost() . $current_base_path; + if (isset($options['https'])) { + if (!empty($options['https'])) { + $base = str_replace('http://', 'https://', $current_base_url); + $options['absolute'] = TRUE; + } + else { + $base = str_replace('https://', 'http://', $current_base_url); + $options['absolute'] = TRUE; + } + } + else { + $base = $current_base_url; + } + } + else { + $base = $current_base_path; + } + + $prefix = empty($uri) ? rtrim($options['prefix'], '/') : $options['prefix']; + + $uri = str_replace('%2F', '/', rawurlencode($prefix . $uri)); + $query = $options['query'] ? ('?' . UrlHelper::buildQuery($options['query'])) : ''; + return $base . $options['script'] . $uri . $query . $options['fragment']; + } + + /** + * Merges in default defaults + * + * @param array $options + * The options to merge in the defaults. + */ + protected function addOptionDefaults(array &$options) { + // Merge in defaults. + $options += [ + 'fragment' => '', + 'query' => [], + 'absolute' => FALSE, + 'prefix' => '', + 'script' => '', + ]; + + if (isset($options['fragment']) && $options['fragment'] !== '') { + $options['fragment'] = '#' . $options['fragment']; + } + } + +} diff --git a/core/lib/Drupal/Core/Utility/UnroutedUrlAssemblerInterface.php b/core/lib/Drupal/Core/Utility/UnroutedUrlAssemblerInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..cba87febdbe9c1b91d84ef41fd66538d1131e9e7 --- /dev/null +++ b/core/lib/Drupal/Core/Utility/UnroutedUrlAssemblerInterface.php @@ -0,0 +1,54 @@ +<?php +/** + * @file + * Contains Drupal\Core\Utility\UnroutedUrlAssemblerInterface. + */ + +namespace Drupal\Core\Utility; + +/** + * Provides a way to build external or non Drupal local domain URLs. + */ +interface UnroutedUrlAssemblerInterface { + + /** + * Builds a domain-local or external URL from a path or URL. + * + * For actual implementations the logic probably has to be split up between + * domain-local and external URLs. + * + * @param string $uri + * A path on the same domain or external URL being linked to, such as "foo" + * or "http://example.com/foo". + * - If you provide a full URL, it will be considered an external URL as + * long as it has an allowed protocol. + * - If you provide only a path (e.g. "foo"), it will be + * considered a URL local to the same domain. Additional query + * arguments for local paths must be supplied in $options['query'], not + * included in $path. + * - If your external URL contains a query (e.g. http://example.com/foo?a=b), + * then you can either URL encode the query keys and values yourself and + * include them in $path, or use $options['query'] to let this method + * URL encode them. + * + * @param array $options + * (optional) An associative array of additional options, with the following + * elements: + * - 'query': An array of query key/value-pairs (without any URL-encoding) to + * append to the URL. + * - 'fragment': A fragment identifier (named anchor) to append to the URL. + * Do not include the leading '#' character. + * - 'absolute': Defaults to FALSE. Whether to force the output to be an + * absolute link (beginning with http:). Useful for links that will be + * displayed outside the site, such as in an RSS feed. + * - 'https': Whether this URL should point to a secure location. If not + * defined, the current scheme is used, so the user stays on HTTP or HTTPS + * respectively. TRUE enforces HTTPS and FALSE enforces HTTP, but HTTPS can + * only be enforced when the variable 'https' is set to TRUE. + * + * @return + * A string containing a relative or absolute URL. + */ + public function assemble($uri, array $options = array()); + +} diff --git a/core/tests/Drupal/Tests/Core/Utility/UnroutedUrlAssemblerTest.php b/core/tests/Drupal/Tests/Core/Utility/UnroutedUrlAssemblerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..517625f8e8c1c3e1ccc9327e06c3dd9bcd824532 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Utility/UnroutedUrlAssemblerTest.php @@ -0,0 +1,129 @@ +<?php + +/** + * @file + * Contains \Drupal\Tests\Core\Utility\UnroutedUrlAssemblerTest. + */ + +namespace Drupal\Tests\Core\Utility; + +use Drupal\Core\Utility\UnroutedUrlAssembler; +use Drupal\Tests\UnitTestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * @coversDefaultClass \Drupal\Core\Utility\UnroutedUrlAssembler + * @group Utility + */ +class UnroutedUrlAssemblerTest extends UnitTestCase { + + /** + * The request stack. + * + * @var \Symfony\Component\HttpFoundation\RequestStack + */ + protected $requestStack; + + /** + * The mocked config factory. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface|\PHPUnit_Framework_MockObject_MockObject + */ + protected $configFactory; + + /** + * The tested unrouted url assembler. + * + * @var \Drupal\Core\Utility\UnroutedUrlAssembler + */ + protected $unroutedUrlAssembler; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + $this->requestStack = new RequestStack(); + $this->configFactory = $this->getConfigFactoryStub(['system.filter' => []]); + $this->unroutedUrlAssembler = new UnroutedUrlAssembler($this->requestStack, $this->configFactory); + } + + /** + * @covers ::assemble + * @expectedException \InvalidArgumentException + */ + public function testAssembleWithNeitherExternalNorDomainLocalUri() { + $this->unroutedUrlAssembler->assemble('wrong-url'); + } + + /** + * @covers ::assemble + * @covers ::buildExternalUrl + * + * @dataProvider providerTestAssembleWithExternalUrl + */ + public function testAssembleWithExternalUrl($uri, array $options, $expected) { + $this->assertEquals($expected, $this->unroutedUrlAssembler->assemble($uri, $options)); + } + + /** + * Provides test data for testAssembleWithExternalUrl + */ + public function providerTestAssembleWithExternalUrl() { + return [ + ['http://example.com/test', [], 'http://example.com/test'], + ['http://example.com/test', ['fragment' => 'example'], 'http://example.com/test#example'], + ['http://example.com/test', ['fragment' => 'example'], 'http://example.com/test#example'], + ['http://example.com/test', ['query' => ['foo' => 'bar']], 'http://example.com/test?foo=bar'], + ['http://example.com/test', ['https' => TRUE], 'https://example.com/test'], + ['https://example.com/test', ['https' => FALSE], 'http://example.com/test'], + ['https://example.com/test?foo=1#bar', [], 'https://example.com/test?foo=1#bar'], + ]; + } + + /** + * @covers ::assemble + * @covers::buildLocalUrl + * + * @dataProvider providerTestAssembleWithLocalUri + */ + public function testAssembleWithLocalUri($uri, array $options, $subdir, $expected) { + $server = []; + if ($subdir) { + // Setup a fake request which looks like a Drupal installed under the + // subdir "subdir" on the domain www.example.com. + // To reproduce the values install Drupal like that and use a debugger. + $server = [ + 'SCRIPT_NAME' => '/subdir/index.php', + 'SCRIPT_FILENAME' => DRUPAL_ROOT . '/index.php', + 'SERVER_NAME' => 'http://www.example.com', + ]; + $request = Request::create('/subdir'); + } + else { + $request = Request::create('/'); + } + $request->server->add($server); + $this->requestStack->push($request); + + $this->assertEquals($expected, $this->unroutedUrlAssembler->assemble($uri, $options)); + } + + /** + * @return array + */ + public function providerTestAssembleWithLocalUri() { + return [ + ['base://example', [], FALSE, '/example'], + ['base://example', ['query' => ['foo' => 'bar']], FALSE, '/example?foo=bar'], + ['base://example', ['fragment' => 'example', ], FALSE, '/example#example'], + ['base://example', [], TRUE, '/subdir/example'], + ['base://example', ['query' => ['foo' => 'bar']], TRUE, '/subdir/example?foo=bar'], + ['base://example', ['fragment' => 'example', ], TRUE, '/subdir/example#example'], + ]; + } + +} +