diff --git a/core/core.services.yml b/core/core.services.yml index bbc9e8e..9c76558 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1242,6 +1242,18 @@ services: class: Drupal\Core\StreamWrapper\TemporaryStream tags: - { name: stream_wrapper, scheme: temporary } + stream_wrapper.module: + class: Drupal\Core\StreamWrapper\ModuleStream + tags: + - { name: stream_wrapper, scheme: module } + stream_wrapper.theme: + class: Drupal\Core\StreamWrapper\ThemeStream + tags: + - { name: stream_wrapper, scheme: theme } + stream_wrapper.profile: + class: Drupal\Core\StreamWrapper\ProfileStream + tags: + - { name: stream_wrapper, scheme: profile } kernel_destruct_subscriber: class: Drupal\Core\EventSubscriber\KernelDestructionSubscriber tags: diff --git a/core/includes/bootstrap.inc b/core/includes/bootstrap.inc index ce872d4..0240643 100644 --- a/core/includes/bootstrap.inc +++ b/core/includes/bootstrap.inc @@ -240,14 +240,18 @@ function drupal_get_filename($type, $name, $filename = NULL) { /** * Returns the path to a system item (module, theme, etc.). * - * @param $type + * This function should only be used when including a file containing PHP code; + * the 'module://', 'profile://' and 'theme://' stream wrappers should be used + * for other use cases. + * + * @param string $type * The type of the item; one of 'core', 'profile', 'module', 'theme', or * 'theme_engine'. * @param $name * The name of the item for which the path is requested. Ignored for * $type 'core'. * - * @return + * @return string * The path to the requested item or an empty string if the item is not found. */ function drupal_get_path($type, $name) { diff --git a/core/lib/Drupal/Core/StreamWrapper/ExtensionStreamBase.php b/core/lib/Drupal/Core/StreamWrapper/ExtensionStreamBase.php new file mode 100644 index 0000000..13cdf21 --- /dev/null +++ b/core/lib/Drupal/Core/StreamWrapper/ExtensionStreamBase.php @@ -0,0 +1,113 @@ +uri, 2); + if (count($uri_parts) === 1) { + // The delimiter ('://') was not found in $uri, malformed $uri passed. + throw new \InvalidArgumentException("Malformed uri parameter passed: {$this->uri}"); + } + + // Remove the trailing filename from the path. + $length = strpos($uri_parts[1], '/'); + return ($length === FALSE) ? $uri_parts[1] : substr($uri_parts[1], 0, $length); + } + + /** + * {@inheritdoc} + */ + protected function getTarget($uri = NULL) { + if ($target = strstr(parent::getTarget($uri), '/')) { + return trim($target, '/'); + } + return ''; + } + + /** + * {@inheritdoc} + */ + public function getExternalUrl() { + $dir = $this->getDirectoryPath(); + if (empty($dir)) { + throw new \InvalidArgumentException("Extension directory for {$this->uri} does not exist."); + } + $path = rtrim(base_path() . $dir . '/' . $this->getTarget(), '/'); + return $this->getRequest()->getUriForPath($path); + } + + /** + * {@inheritdoc} + */ + public function dirname($uri = NULL) { + if (!isset($uri)) { + $uri = $this->uri; + } + + list($scheme) = explode('://', $uri, 2); + $dirname = dirname($this->getTarget($uri)); + $dirname = $dirname !== '.' ? rtrim("/$dirname", '/') : ''; + + return "$scheme://{$this->getOwnerName()}{$dirname}"; + } + + /** + * Returns the current request object. + * + * @return \Symfony\Component\HttpFoundation\Request + * The current request object. + */ + protected function getRequest() { + if (!isset($this->request)) { + $this->request = \Drupal::service('request_stack')->getCurrentRequest(); + } + return $this->request; + } + +} diff --git a/core/lib/Drupal/Core/StreamWrapper/LocalReadOnlyStream.php b/core/lib/Drupal/Core/StreamWrapper/LocalReadOnlyStream.php index ad4636c..f37aa25 100644 --- a/core/lib/Drupal/Core/StreamWrapper/LocalReadOnlyStream.php +++ b/core/lib/Drupal/Core/StreamWrapper/LocalReadOnlyStream.php @@ -47,15 +47,7 @@ public function stream_open($uri, $mode, $options, &$opened_path) { } return FALSE; } - - $this->uri = $uri; - $path = $this->getLocalPath(); - $this->handle = ($options & STREAM_REPORT_ERRORS) ? fopen($path, $mode) : @fopen($path, $mode); - if ($this->handle !== FALSE && ($options & STREAM_USE_PATH)) { - $opened_path = $path; - } - - return (bool) $this->handle; + return parent::stream_open($uri, $mode, $options, $opened_path); } /** diff --git a/core/lib/Drupal/Core/StreamWrapper/LocalStream.php b/core/lib/Drupal/Core/StreamWrapper/LocalStream.php index 82f4427..87c2ce2 100644 --- a/core/lib/Drupal/Core/StreamWrapper/LocalStream.php +++ b/core/lib/Drupal/Core/StreamWrapper/LocalStream.php @@ -15,10 +15,13 @@ * "sites/default/files/example.txt" and then PHP filesystem functions are * invoked. * - * Drupal\Core\StreamWrapper\LocalStream implementations need to implement at least the - * getDirectoryPath() and getExternalUrl() methods. + * \Drupal\Core\StreamWrapper\LocalStream implementations need to implement at + * least the getDirectoryPath() and getExternalUrl() methods. */ abstract class LocalStream implements StreamWrapperInterface { + + use LocalStreamTrait; + /** * Stream context resource. * @@ -52,12 +55,10 @@ public static function getType() { /** * Gets the path that the wrapper is responsible for. * - * @todo Review this method name in D8 per https://www.drupal.org/node/701358. - * * @return string * String specifying the path. */ - abstract function getDirectoryPath(); + protected abstract function getDirectoryPath(); /** * {@inheritdoc} @@ -74,36 +75,9 @@ function getUri() { } /** - * Returns the local writable target of the resource within the stream. - * - * This function should be used in place of calls to realpath() or similar - * functions when attempting to determine the location of a file. While - * functions like realpath() may return the location of a read-only file, this - * method may return a URI or path suitable for writing that is completely - * separate from the URI used for reading. - * - * @param string $uri - * Optional URI. - * - * @return string|bool - * Returns a string representing a location suitable for writing of a file, - * or FALSE if unable to write to the file such as with read-only streams. - */ - protected function getTarget($uri = NULL) { - if (!isset($uri)) { - $uri = $this->uri; - } - - list(, $target) = explode('://', $uri, 2); - - // Remove erroneous leading or trailing, forward-slashes and backslashes. - return trim($target, '\/'); - } - - /** * {@inheritdoc} */ - function realpath() { + public function realpath() { return $this->getLocalPath(); } @@ -142,6 +116,7 @@ protected function getLocalPath($uri = NULL) { $realpath = realpath(dirname($path)) . '/' . drupal_basename($path); } $directory = realpath($this->getDirectoryPath()); + if (!$realpath || !$directory || strpos($realpath, $directory) !== 0) { return FALSE; } @@ -170,7 +145,7 @@ public function stream_open($uri, $mode, $options, &$opened_path) { $path = $this->getLocalPath(); $this->handle = ($options & STREAM_REPORT_ERRORS) ? fopen($path, $mode) : @fopen($path, $mode); - if ((bool) $this->handle && $options & STREAM_USE_PATH) { + if ((bool) $this->handle && ($options & STREAM_USE_PATH)) { $opened_path = $path; } @@ -400,33 +375,6 @@ public function rename($from_uri, $to_uri) { } /** - * Gets the name of the directory from a given path. - * - * This method is usually accessed through drupal_dirname(), which wraps - * around the PHP dirname() function because it does not support stream - * wrappers. - * - * @param string $uri - * A URI or path. - * - * @return string - * A string containing the directory name. - * - * @see drupal_dirname() - */ - public function dirname($uri = NULL) { - list($scheme) = explode('://', $uri, 2); - $target = $this->getTarget($uri); - $dirname = dirname($target); - - if ($dirname == '.') { - $dirname = ''; - } - - return $scheme . '://' . $dirname; - } - - /** * Support for mkdir(). * * @param string $uri diff --git a/core/lib/Drupal/Core/StreamWrapper/LocalStreamTrait.php b/core/lib/Drupal/Core/StreamWrapper/LocalStreamTrait.php new file mode 100644 index 0000000..0fcc87e --- /dev/null +++ b/core/lib/Drupal/Core/StreamWrapper/LocalStreamTrait.php @@ -0,0 +1,72 @@ +uri; + } + + list($scheme) = explode('://', $uri, 2); + $dirname = dirname($this->getTarget($uri)); + + return $dirname !== '.' ? "$scheme://$dirname" : "$scheme://"; + } + + /** + * Returns the local writable target of the resource within the stream. + * + * This function should be used in place of calls to realpath() or similar + * functions when attempting to determine the location of a file. While + * functions like realpath() may return the location of a read-only file, this + * method may return a URI or path suitable for writing that is completely + * separate from the URI used for reading. + * + * @param string $uri + * Optional URI. + * + * @return string + * Returns a string representing a location suitable for writing of a file. + * + * @throws \InvalidArgumentException + * If a malformed $uri parameter is passed in. + */ + protected function getTarget($uri = NULL) { + if (!isset($uri)) { + $uri = $this->uri; + } + + $uri_parts = explode('://', $uri, 2); + if (count($uri_parts) === 1) { + // The delimiter ('://') was not found in $uri, malformed $uri passed. + throw new \InvalidArgumentException("Malformed uri parameter passed: $uri"); + } + + // Remove erroneous leading or trailing forward-slashes and backslashes. + return trim($uri_parts[1], '\/'); + } + + +} diff --git a/core/lib/Drupal/Core/StreamWrapper/ModuleStream.php b/core/lib/Drupal/Core/StreamWrapper/ModuleStream.php new file mode 100644 index 0000000..157b876 --- /dev/null +++ b/core/lib/Drupal/Core/StreamWrapper/ModuleStream.php @@ -0,0 +1,68 @@ +getModuleHandler()->moduleExists($name)) { + // The module does not exist or is not installed. + throw new \InvalidArgumentException("Module $name does not exist or is not installed"); + } + return $name; + } + + /** + * {@inheritdoc} + */ + protected function getDirectoryPath() { + return $this->getModuleHandler()->getModule($this->getOwnerName())->getPath(); + } + + /** + * {@inheritdoc} + */ + public function getName() { + return $this->t('Module files'); + } + + /** + * {@inheritdoc} + */ + public function getDescription() { + return $this->t('Local files stored under module directory.'); + } + + /** + * Returns the module handler service. + * + * @return \Drupal\Core\Extension\ModuleHandlerInterface + * The module handler service. + */ + protected function getModuleHandler() { + if (!isset($this->moduleHandler)) { + $this->moduleHandler = \Drupal::moduleHandler(); + } + return $this->moduleHandler; + } + +} diff --git a/core/lib/Drupal/Core/StreamWrapper/ProfileStream.php b/core/lib/Drupal/Core/StreamWrapper/ProfileStream.php new file mode 100644 index 0000000..e016a42 --- /dev/null +++ b/core/lib/Drupal/Core/StreamWrapper/ProfileStream.php @@ -0,0 +1,38 @@ +t('Installed profile files'); + } + + /** + * {@inheritdoc} + */ + public function getDescription() { + return $this->t('Local files stored under installed profile directory.'); + } + +} diff --git a/core/lib/Drupal/Core/StreamWrapper/StreamWrapperInterface.php b/core/lib/Drupal/Core/StreamWrapper/StreamWrapperInterface.php index f13cfb8..2bf1502 100644 --- a/core/lib/Drupal/Core/StreamWrapper/StreamWrapperInterface.php +++ b/core/lib/Drupal/Core/StreamWrapper/StreamWrapperInterface.php @@ -182,7 +182,7 @@ public function realpath(); * An optional URI. * * @return string - * A string containing the directory name, or FALSE if not applicable. + * A string containing the directory name. * * @see drupal_dirname() */ diff --git a/core/lib/Drupal/Core/StreamWrapper/ThemeStream.php b/core/lib/Drupal/Core/StreamWrapper/ThemeStream.php new file mode 100644 index 0000000..bf100ac --- /dev/null +++ b/core/lib/Drupal/Core/StreamWrapper/ThemeStream.php @@ -0,0 +1,68 @@ +getThemeHandler()->themeExists($name)) { + // The theme does not exist or is not installed. + throw new \InvalidArgumentException("Theme $name does not exist or is not installed"); + } + return $name; + } + + /** + * {@inheritdoc} + */ + protected function getDirectoryPath() { + return $this->getThemeHandler()->getTheme($this->getOwnerName())->getPath(); + } + + /** + * {@inheritdoc} + */ + public function getName() { + return $this->t('Theme files'); + } + + /** + * {@inheritdoc} + */ + public function getDescription() { + return $this->t('Local files stored under theme directory.'); + } + + /** + * Returns the theme handler service. + * + * @return \Drupal\Core\Extension\ThemeHandlerInterface + * The theme handler service. + */ + protected function getThemeHandler() { + if (!isset($this->themeHandler)) { + $this->themeHandler = \Drupal::service('theme_handler'); + } + return $this->themeHandler; + } + +} diff --git a/core/modules/color/tests/modules/color_test/color_test.module b/core/modules/color/tests/modules/color_test/color_test.module index 603906e..b89e5e4 100644 --- a/core/modules/color/tests/modules/color_test/color_test.module +++ b/core/modules/color/tests/modules/color_test/color_test.module @@ -9,6 +9,6 @@ * Implements hook_system_theme_info(). */ function color_test_system_theme_info() { - $themes['color_test_theme'] = drupal_get_path('module', 'color_test') . '/themes/color_test_theme/color_test_theme.info.yml'; + $themes['color_test_theme'] = 'module://color_test/themes/color_test_theme/color_test_theme.info.yml'; return $themes; } diff --git a/core/modules/file/tests/file_test/file_test.dummy.inc b/core/modules/file/tests/file_test/file_test.dummy.inc new file mode 100644 index 0000000..a09fd2e --- /dev/null +++ b/core/modules/file/tests/file_test/file_test.dummy.inc @@ -0,0 +1 @@ +Dummy file used by SystemStreamTest.php. diff --git a/core/modules/search/src/Tests/SearchSimplifyTest.php b/core/modules/search/src/Tests/SearchSimplifyTest.php index 82363fd..6780ba0 100644 --- a/core/modules/search/src/Tests/SearchSimplifyTest.php +++ b/core/modules/search/src/Tests/SearchSimplifyTest.php @@ -25,7 +25,7 @@ function testSearchSimplifyUnicode() { // their own lines). So the even-numbered lines should simplify to nothing, // and the odd-numbered lines we need to split into shorter chunks and // verify that simplification doesn't lose any characters. - $input = file_get_contents(\Drupal::root() . '/core/modules/search/tests/UnicodeTest.txt'); + $input = file_get_contents('module://search/tests/UnicodeTest.txt'); $basestrings = explode(chr(10), $input); $strings = array(); foreach ($basestrings as $key => $string) { diff --git a/core/modules/system/tests/src/Kernel/File/ExtensionStreamTest.php b/core/modules/system/tests/src/Kernel/File/ExtensionStreamTest.php new file mode 100644 index 0000000..565c6ef --- /dev/null +++ b/core/modules/system/tests/src/Kernel/File/ExtensionStreamTest.php @@ -0,0 +1,259 @@ +baseUrl = $this->container->get('request_stack')->getCurrentRequest()->getUriForPath(base_path()); + + /** @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $stream_wrapper_manager */ + $stream_wrapper_manager = $this->container->get('stream_wrapper_manager'); + // Get stream wrapper instances. + foreach (['module', 'theme', 'profile'] as $scheme) { + $this->streamWrappers[$scheme] = $stream_wrapper_manager->getViaScheme($scheme); + } + + /** @var \Drupal\Core\State\StateInterface $state */ + $state = $this->container->get('state'); + + // Set 'minimal' as installed profile for the purposes of this test. + $system_module_files = $state->get('system.module.files', []); + $system_module_files += ['minimal' => 'core/profiles/minimal/minimal.info.yml']; + $state->set('system.module.files', $system_module_files); + // Add default profile for the purposes of this test. + new Settings(Settings::getAll() + ['install_profile' => 'minimal']); + $this->config('core.extension')->set('module.minimal', 0)->save(); + $this->container->get('module_handler')->addProfile('minimal', 'core/profiles/minimal'); + + /** @var \Drupal\Core\Extension\ThemeInstallerInterface $theme_installer */ + $theme_installer = $this->container->get('theme_installer'); + // Install Bartik and Seven themes. + $theme_installer->install(['bartik', 'seven']); + } + + /** + * Tests invalid stream uris. + * + * @param string $uri + * The URI being tested. + * + * @dataProvider providerInvalidUris + */ + public function testInvalidStreamUri($uri) { + $message = "\\InvalidArgumentException thrown on invalid uri $uri."; + try { + $this->streamWrappers['module']->dirname($uri); + $this->fail($message); + } + catch (\InvalidArgumentException $e) { + $this->assertSame($e->getMessage(), "Malformed uri parameter passed: $uri", $message); + } + } + + /** + * Provides test cases for testInvalidStreamUri() + * + * @return array[] + * A list of urls to test. + */ + public function providerInvalidUris() { + return [ + ['invalid/uri'], + ['invalid_uri'], + ['module/invalid/uri'], + ['module/invalid_uri'], + ['module:invalid_uri'], + ['module::/invalid/uri'], + ['module::/invalid_uri'], + ['module//:invalid/uri'], + ['module//invalid_uri'], + ['module//invalid/uri'], + ]; + } + + /** + * Test the extension stream wrapper methods. + * + * @param string $uri + * The uri to be tested, + * @param string|\InvalidArgumentException $dirname + * The expectation for dirname() method. + * @param string|\InvalidArgumentException $realpath + * The expectation for realpath() method. + * @param string|\InvalidArgumentException $getExternalUrl + * The expectation for getExternalUrl() method. + * + * @dataProvider providerStreamWrapperMethods + */ + public function testStreamWrapperMethods($uri, $dirname, $realpath, $getExternalUrl) { + // Prefix realpath() expected value with Drupal root directory. + $realpath = is_string($realpath) ? DRUPAL_ROOT . $realpath : $realpath; + // Prefix getExternalUrl() expected value with base url. + $getExternalUrl = is_string($getExternalUrl) ? "{$this->baseUrl}$getExternalUrl" : $getExternalUrl; + $case = compact($dirname, $realpath, $getExternalUrl); + + foreach ($case as $method => $expected) { + list($scheme, ) = explode('://', $uri); + $this->streamWrappers[$scheme]->setUri($uri); + if ($expected instanceof \InvalidArgumentException) { + /** @var \InvalidArgumentException $expected */ + $message = sprintf('Exception thrown: \InvalidArgumentException("%s").', $expected->getMessage()); + try { + $this->streamWrappers[$scheme]->$method(); + $this->fail($message); + } + catch (\InvalidArgumentException $e) { + $this->assertSame($expected->getMessage(), $e->getMessage(), $message); + } + } + elseif (is_string($expected)) { + $this->assertSame($expected, $this->streamWrappers[$scheme]->$method()); + } + } + } + + /** + * Provides test cases for testStreamWrapperMethods(). + * + * @return array[] + * A list of test cases. Each case consists of the following items: + * - The uri to be tested. + * - The result or the exception when running dirname() method. + * - The result or the exception when running realpath() method. The value + * is prefixed later, in the test method, with the Drupal root directory. + * - The result or the exception when running getExternalUrl() method. The + * value is prefixed later, in the test method, with the base url. + */ + public function providerStreamWrapperMethods() { + return [ + // Cases for module:// stream wrapper. + [ + 'module://system', + 'module://system', + '/core/modules/system', + 'core/modules/system', + ], + [ + 'module://system/css/system.admin.css', + 'module://system/css', + '/core/modules/system/css/system.admin.css', + 'core/modules/system/css/system.admin.css', + ], + [ + 'module://file_test/file_test.dummy.inc', + 'module://file_test', + '/core/modules/file/tests/file_test/file_test.dummy.inc', + 'core/modules/file/tests/file_test/file_test.dummy.inc', + ], + [ + 'module://file_test/src/file_test.dummy.inc', + 'module://file_test/src', + '/core/modules/file/tests/file_test/src/file_test.dummy.inc', + 'core/modules/file/tests/file_test/src/file_test.dummy.inc', + ], + [ + 'module://ckeditor/ckeditor.info.yml', + new \InvalidArgumentException('Module ckeditor does not exist or is not installed'), + new \InvalidArgumentException('Module ckeditor does not exist or is not installed'), + new \InvalidArgumentException('Module ckeditor does not exist or is not installed'), + ], + [ + 'module://foo_bar/foo.bar.js', + new \InvalidArgumentException('Module foo_bar does not exist or is not installed'), + new \InvalidArgumentException('Module foo_bar does not exist or is not installed'), + new \InvalidArgumentException('Module foo_bar does not exist or is not installed'), + ], + // Cases for theme:// stream wrapper. + [ + 'theme://seven', + 'theme://seven', + '/core/themes/seven', + 'core/themes/seven', + ], + [ + 'theme://seven/style.css', + 'theme://seven', + '/core/themes/seven/style.css', + 'core/themes/seven/style.css', + ], + [ + 'theme://bartik/color/preview.js', + 'theme://bartik/color', + '/core/themes/bartik/color/preview.js', + 'core/themes/bartik/color/preview.js', + ], + [ + 'theme://fifteen/screenshot.png', + new \InvalidArgumentException('Theme fifteen does not exist or is not installed'), + new \InvalidArgumentException('Theme fifteen does not exist or is not installed'), + new \InvalidArgumentException('Theme fifteen does not exist or is not installed'), + ], + [ + 'theme://stark/stark.info.yml', + new \InvalidArgumentException('Theme stark does not exist or is not installed'), + new \InvalidArgumentException('Theme stark does not exist or is not installed'), + new \InvalidArgumentException('Theme stark does not exist or is not installed'), + ], + // Cases for profile:// stream wrapper. + [ + 'profile://', + 'profile://', + '/core/profiles/minimal', + 'core/profiles/minimal', + ], + [ + 'profile://config/install/block.block.stark_login.yml', + 'profile://config/install', + '/core/profiles/minimal/config/install/block.block.stark_login.yml', + 'core/profiles/minimal/config/install/block.block.stark_login.yml', + ], + [ + 'profile://config/install/node.type.article.yml', + 'profile://config/install', + '/core/profiles/minimal/config/install/node.type.article.yml', + 'core/profiles/minimal/config/install/node.type.article.yml', + ], + [ + 'profile://minimal.info.yml', + 'profile://', + '/core/profiles/minimal/minimal.info.yml', + 'core/profiles/minimal/minimal.info.yml', + ], + ]; + } + +}