diff --git a/cdn.install b/cdn.install index c788522..fc16613 100644 --- a/cdn.install +++ b/cdn.install @@ -20,3 +20,10 @@ function cdn_update_8001() { $cdn_settings->save(); } } + +/** + * Add the new "stream wrappers" setting, set it to its default initial value. + */ +function cdn_update_8002() { + \Drupal::configFactory()->getEditable('cdn.settings')->set('stream_wrappers', ['public'])->save(); +} diff --git a/cdn.routing.yml b/cdn.routing.yml index 6a53eb5..6912dbf 100644 --- a/cdn.routing.yml +++ b/cdn.routing.yml @@ -1,7 +1,17 @@ +# The /cdn/farfuture route has been deprecated and is rewritten +# in CdnFarfuturePathProcessor. cdn.farfuture.download: path: '/cdn/farfuture/{security_token}/{mtime}' defaults: _controller: cdn.controller.farfuture:download + _disable_route_normalizer: TRUE + requirements: + _access: 'TRUE' + mtime: \d+ +cdn.farfuture_scheme.download: + path: '/cdn/ff/{security_token}/{mtime}/{scheme}' + defaults: + _controller: cdn.controller.farfuture:downloadByScheme # Ensure the redirect module does not redirect to add a language prefix. # @see \Drupal\redirect\EventSubscriber\RouteNormalizerRequestSubscriber # @todo Update this comment when https://www.drupal.org/project/drupal/issues/2641118 lands. @@ -9,3 +19,4 @@ cdn.farfuture.download: requirements: _access: 'TRUE' mtime: \d+ + scheme: '(:\w+:)|([a-zA-Z0-9+.-]+)' diff --git a/cdn.services.yml b/cdn.services.yml index 61f94d3..8e86ad9 100644 --- a/cdn.services.yml +++ b/cdn.services.yml @@ -10,7 +10,7 @@ services: # Event subscribers. cdn.config_subscriber: class: Drupal\cdn\EventSubscriber\ConfigSubscriber - arguments: ['@cache_tags.invalidator', '@kernel', '@config.factory'] + arguments: ['@cache_tags.invalidator', '@config.typed', '@kernel'] tags: - { name: event_subscriber } cdn.html_response_subscriber: @@ -22,7 +22,7 @@ services: # Controllers. cdn.controller.farfuture: class: \Drupal\cdn\CdnFarfutureController - arguments: ['@private_key'] + arguments: ['@private_key', '@file_system'] # Inbound path processor for the cdn.farfuture.download route, since the # Drupal 8/Symfony routing system does not support "menu tail" or "slash in diff --git a/cdn_ui/js/summaries.js b/cdn_ui/js/summaries.js index 64696be..0be8aa3 100644 --- a/cdn_ui/js/summaries.js +++ b/cdn_ui/js/summaries.js @@ -15,6 +15,15 @@ return document.querySelector('input[name="status"]').checked ? Drupal.t('Enabled') : Drupal.t('Disabled'); }); + $('[data-drupal-selector="edit-wrappers"]').drupalSetSummary(function () { + var additional = $('[data-drupal-selector="edit-wrappers-stream-wrappers"] input:checked'); + var wrappers = []; + additional.each(function(index) { + wrappers.push(this.getAttribute('value')); + }); + return wrappers.join(', '); + }); + $('[data-drupal-selector="edit-mapping"]').drupalSetSummary(function () { if (document.querySelector('select[name="mapping[type]"]').value === 'simple') { var domain = document.querySelector('input[name="mapping[simple][domain]"]').value; diff --git a/cdn_ui/src/Form/CdnSettingsForm.php b/cdn_ui/src/Form/CdnSettingsForm.php index df30c30..c03bff2 100644 --- a/cdn_ui/src/Form/CdnSettingsForm.php +++ b/cdn_ui/src/Form/CdnSettingsForm.php @@ -1,15 +1,45 @@ streamWrapperManager = $streamWrapperManager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('config.factory'), + $container->get('config.typed'), + $container->get('stream_wrapper_manager') + ); + } /** * {@inheritdoc} @@ -25,6 +55,13 @@ class CdnSettingsForm extends ConfigFormBase { return ['cdn.settings']; } + /** + * {@inheritdoc} + */ + protected static function getMainConfigName() { + return 'cdn.settings'; + } + /** * {@inheritdoc} */ @@ -146,32 +183,94 @@ class CdnSettingsForm extends ConfigFormBase { '#default_value' => $config->get('farfuture.status'), ]; + $visible_stream_wrappers = $this->streamWrapperManager->getWrappers(StreamWrapperInterface::VISIBLE); + $non_core_visible_stream_wrappers = array_filter($visible_stream_wrappers, function (array $metadata) { + return strpos($metadata['class'], 'Drupal\Core') !== 0; + }); + $form['wrappers'] = [ + '#type' => 'details', + '#title' => $this->t('Stream wrappers'), + '#group' => 'cdn_settings', + '#tree' => TRUE, + '#access' => count($non_core_visible_stream_wrappers), + ]; + $checkboxes = $this->buildStreamWrapperCheckboxes(array_keys($visible_stream_wrappers)); + $form['wrappers']['stream_wrappers'] = [ + '#type' => 'checkboxes', + '#options' => array_combine(array_keys($checkboxes), array_keys($checkboxes)), + '#default_value' => $config->get('stream_wrappers'), + '#description' => $this->t('Stream wrappers whose files to serve from CDN. public:// is always enabled, any other stream wrapper generating local file URLs is eligible.'), + ]; + $form['wrappers']['stream_wrappers'] += $checkboxes; + // Special cases: public:// and private://. + $form['wrappers']['stream_wrappers']['public']['#disabled'] = TRUE; + $form['wrappers']['stream_wrappers']['private']['#title'] = '' . $form['wrappers']['stream_wrappers']['private']['#title'] . ''; + $form['wrappers']['stream_wrappers']['private']['#description'] = $this->t('Private files require authentication and hence cannot be served from a CDN.'); + return parent::buildForm($form, $form_state); } /** - * {@inheritdoc} + * Determines whether the stream wrapper generates external URLs. + * + * @param string $stream_wrapper_scheme + * A valid stream wrapper scheme. + * @param \Drupal\Core\StreamWrapper\StreamWrapperInterface $stream_wrapper + * A stream wrapper instance. + * + * @return bool */ - public function validateForm(array &$form, FormStateInterface $form_state) { - $mapping = $form_state->getValue('mapping'); - if ($mapping['type'] === 'simple') { - if (!CdnSettings::isValidCdnDomain($mapping['simple']['domain'])) { - $form_state->setErrorByName('mapping][simple][domain', $this->t('The provided domain %domain is not valid. Provide a hostname like cdn.com or cdn.example.com. IP addresses and ports are also allowed.', [ - '%domain' => $mapping['simple']['domain'], - ])); - } + protected function streamWrapperGeneratesExternalUrls($stream_wrapper_scheme, StreamWrapperInterface $stream_wrapper) { + // Generate URL to imaginary file 'cdn.test'. Most stream wrappers don't + // check file existence, just concatenate strings. + $stream_wrapper->setUri($stream_wrapper_scheme . '://cdn.test'); + try { + $absolute_url = $stream_wrapper->getExternalUrl(); + $base_url = $this->getRequest()->getSchemeAndHttpHost() . $this->getRequest()->getBasePath(); + $relative_url = str_replace($base_url, '', $absolute_url); + return UrlHelper::isExternal($relative_url); + } + catch (\Exception $e) { + // In case of failure, assume this would have resulted in an external URL. + return TRUE; } } /** - * {@inheritdoc} + * Builds the stream wrapper checkboxes form array. + * + * @param string[] $stream_wrapper_schemes + * The stream wrapper schemes for which to generate form checkboxes. + * + * @return array */ - public function submitForm(array &$form, FormStateInterface $form_state) { - $config = $this->config('cdn.settings'); + protected function buildStreamWrapperCheckboxes(array $stream_wrapper_schemes) { + $checkboxes = []; + foreach ($stream_wrapper_schemes as $stream_wrapper_scheme) { + $wrapper = $this->streamWrapperManager->getViaScheme($stream_wrapper_scheme); + $generates_external_urls = static::streamWrapperGeneratesExternalUrls($stream_wrapper_scheme, $wrapper); + $checkboxes[$stream_wrapper_scheme] = [ + '#title' => $this->t('@name → @scheme://', ['@scheme' => $stream_wrapper_scheme, '@name' => $wrapper->getName()]), + '#disabled' => $generates_external_urls, + '#description' => !$generates_external_urls ? NULL : $this->t('This stream wrapper generates external URLs, and hence cannot be served from a CDN.'), + ]; + } + return $checkboxes; + } + /** + * {@inheritdoc} + */ + protected static function mapFormValuesToConfig(FormStateInterface $form_state, Config $config) { // Vertical tab: 'Status'. $config->set('status', (bool) $form_state->getValue('status')); + // Vertical tab: 'Additional stream wrappers'. + $stream_wrappers = array_values(array_filter($form_state->getValue(['wrappers', 'stream_wrappers']))); + // Ensure 'public://' is always enabled, and ensure it's always first. + $stream_wrappers = array_merge(['public'], $stream_wrappers); + $config->set('stream_wrappers', $stream_wrappers); + // Vertical tab: 'Mapping'. if ($form_state->getValue(['mapping', 'type']) === 'simple') { $simple_mapping = $form_state->getValue(['mapping', 'simple']); @@ -204,9 +303,20 @@ class CdnSettingsForm extends ConfigFormBase { // Vertical tab: 'Forever cacheable files'. $config->set('farfuture.status', (bool) $form_state->getValue(['farfuture', 'status'])); - $config->save(); + return $config; + } + + /** + * {@inheritdoc} + */ + protected static function mapViolationPropertyPathsToFormNames($property_path) { + switch ($property_path) { + case 'mapping.domain': + return 'mapping][simple][domain'; - parent::submitForm($form, $form_state); + default: + return parent::mapViolationPropertyPathsToFormNames($property_path); + } } } diff --git a/cdn_ui/src/Form/ValidatableConfigFormBase.php b/cdn_ui/src/Form/ValidatableConfigFormBase.php new file mode 100644 index 0000000..374990a --- /dev/null +++ b/cdn_ui/src/Form/ValidatableConfigFormBase.php @@ -0,0 +1,70 @@ +get('config.factory'), + $container->get('config.typed') + ); + } + + public function __construct(ConfigFactoryInterface $config_factory, TypedConfigManagerInterface $typed_config_manager) { + $this->typedConfigManager = $typed_config_manager; + parent::__construct($config_factory); + } + + abstract protected static function getMainConfigName(); + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + $config = static::mapFormValuesToConfig($form_state, $this->config(static::getMainConfigName())); + $typed_config = $this->typedConfigManager->createFromNameAndData(static::getMainConfigName(), $config->getRawData()); + + $violations = $typed_config->validate(); + foreach ($violations as $violation) { + $form_state->setErrorByName(static::mapViolationPropertyPathsToFormNames($violation->getPropertyPath()), $violation->getMessage()); + } + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $config = static::mapFormValuesToConfig($form_state, $this->config(static::getMainConfigName())); + $config->save(); + parent::submitForm($form, $form_state); + } + + abstract protected static function mapFormValuesToConfig(FormStateInterface $form_state, Config $config); + + protected static function mapViolationPropertyPathsToFormNames($property_path) { + return str_replace('.', '][', $property_path); + } + +} diff --git a/config/install/cdn.settings.yml b/config/install/cdn.settings.yml index 0ed3fc6..0b12ad9 100644 --- a/config/install/cdn.settings.yml +++ b/config/install/cdn.settings.yml @@ -69,3 +69,6 @@ mapping: farfuture: status: true +# Public is enabled by default, additional local stream wrappers can be added. +stream_wrappers: + - public diff --git a/config/schema/cdn.data_types.schema.yml b/config/schema/cdn.data_types.schema.yml index 58213e2..b195d91 100644 --- a/config/schema/cdn.data_types.schema.yml +++ b/config/schema/cdn.data_types.schema.yml @@ -5,3 +5,8 @@ cdn.domain: type: string label: 'Domain' + stream_wrapper_scheme: + type: string + label: 'Stream wrapper scheme' + constraints: + CdnDomain: [] diff --git a/config/schema/cdn.schema.yml b/config/schema/cdn.schema.yml index b0d9272..ec8c562 100644 --- a/config/schema/cdn.schema.yml +++ b/config/schema/cdn.schema.yml @@ -20,3 +20,8 @@ cdn.settings: status: label: 'Forever cacheable files — status' type: boolean + stream_wrappers: + label: 'CDN-enabled stream wrappers' + type: sequence + sequence: + type: stream_wrapper_scheme diff --git a/src/CdnFarfutureController.php b/src/CdnFarfutureController.php index e1f7bea..0ef5fda 100644 --- a/src/CdnFarfutureController.php +++ b/src/CdnFarfutureController.php @@ -2,7 +2,9 @@ namespace Drupal\cdn; +use Drupal\cdn\File\FileUrlGenerator; use Drupal\Component\Utility\Crypt; +use Drupal\Core\File\FileSystemInterface; use Drupal\Core\PrivateKey; use Drupal\Core\Site\Settings; use Symfony\Component\HttpFoundation\BinaryFileResponse; @@ -19,12 +21,73 @@ class CdnFarfutureController { */ protected $privateKey; + /** + * The file system service. + * + * @var \Drupal\Core\File\FileSystemInterface + */ + protected $fileSystem; + /** * @param \Drupal\Core\PrivateKey $private_key * The private key service. */ - public function __construct(PrivateKey $private_key) { + public function __construct(PrivateKey $private_key, FileSystemInterface $fileSystem) { $this->privateKey = $private_key; + $this->fileSystem = $fileSystem; + } + + /** + * Serves the requested file with optimal far future expiration headers. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The current request. $request->query must have root_relative_file_url, + * set by \Drupal\cdn\PathProcessor\CdnFarfuturePathProcessor. + * @param string $security_token + * The security token. Ensures that users can not request any file they want + * by manipulating the URL (they could otherwise request settings.php for + * example). See https://www.drupal.org/node/1441502. + * @param int $mtime + * The file's mtime. + * @param string $scheme + * The file's scheme. + * + * @returns \Symfony\Component\HttpFoundation\BinaryFileResponse + * The response that will efficiently send the requested file. + * + * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * Thrown when the 'root_relative_file_url' query argument is not set, which + * can only happen in case of malicious requests or in case of a malfunction + * in \Drupal\cdn\PathProcessor\CdnFarfuturePathProcessor. + * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException + * Thrown when an invalid security token is provided. + */ + public function downloadByScheme(Request $request, $security_token, $mtime, $scheme) { + // Validate the scheme early. + if (!$request->query->has('relative_file_url') || ($scheme != FileUrlGenerator::RELATIVE && !$this->fileSystem->validScheme($scheme))) { + throw new BadRequestHttpException(); + } + + $path = $request->query->get('relative_file_url'); + // A relative URL for a file contains '%20' instead of spaces. A relative + // file path contains spaces. + $uri = $scheme == FileUrlGenerator::RELATIVE + ? $path + : $scheme . ':/' . $path; // Path comes with a leading slash from the URL. + // Validate security token. + $calculated_token = Crypt::hmacBase64($mtime . $scheme . $path, $this->privateKey->get() . Settings::getHashSalt()); + if ($security_token !== $calculated_token) { + throw new AccessDeniedHttpException('Invalid security token.'); + } + + // Strip the leading slash for truly relative paths. + if ($scheme == FileUrlGenerator::RELATIVE) { + $uri = substr($path, 1); + } + + $response = new BinaryFileResponse(rawurldecode($uri), 200, $this->getFarfutureHeaders(), TRUE, NULL, FALSE, FALSE); + $response->isNotModified($request); + return $response; } /** @@ -49,6 +112,8 @@ class CdnFarfutureController { * in \Drupal\cdn\PathProcessor\CdnFarfuturePathProcessor. * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException * Thrown when an invalid security token is provided. + * + * @deprecated This method is deprecated in favor of ::downloadByScheme */ public function download(Request $request, $security_token, $mtime) { // Ensure \Drupal\cdn\PathProcessor\CdnFarfuturePathProcessor did its job. @@ -63,7 +128,22 @@ class CdnFarfutureController { throw new AccessDeniedHttpException('Invalid security token.'); } - $farfuture_headers = [ + // A relative URL for a file contains '%20' instead of spaces. A relative + // file path contains spaces. + $relative_file_path = rawurldecode($root_relative_file_url); + + $response = new BinaryFileResponse(substr($relative_file_path, 1), 200, $this->getFarfutureHeaders(), TRUE, NULL, FALSE, FALSE); + $response->isNotModified($request); + return $response; + } + + /** + * Return the headers to serve with far future responses. + * + * @return string[] + */ + protected function getFarfutureHeaders() { + return [ // Instead of being powered by PHP, tell the world this resource was // powered by the CDN module! 'X-Powered-By' => 'Drupal CDN module (https://www.drupal.org/project/cdn)', @@ -97,14 +177,6 @@ class CdnFarfutureController { // Also see http://code.google.com/speed/page-speed/docs/caching.html. 'Last-Modified' => 'Wed, 20 Jan 1988 04:20:42 GMT', ]; - - // A relative URL for a file contains '%20' instead of spaces. A relative - // file path contains spaces. - $relative_file_path = rawurldecode($root_relative_file_url); - - $response = new BinaryFileResponse(substr($relative_file_path, 1), 200, $farfuture_headers, TRUE, NULL, FALSE, FALSE); - $response->isNotModified($request); - return $response; } } diff --git a/src/CdnSettings.php b/src/CdnSettings.php index 45da071..f2c4a25 100644 --- a/src/CdnSettings.php +++ b/src/CdnSettings.php @@ -2,6 +2,8 @@ namespace Drupal\cdn; +use Drupal\Component\Assertion\Inspector; +use Drupal\Component\Utility\Unicode; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Config\ConfigValueException; @@ -77,6 +79,18 @@ class CdnSettings { return $unique_domains; } + /** + * Returns CDN-eligible stream wrappers. + * + * @return string[] The allowed stream wrapper scheme names. + */ + public function getStreamWrappers() { + $stream_wrappers = $this->rawSettings->get('stream_wrappers'); + // @see cdn_update_8002() + assert(Inspector::assertAllStrings($stream_wrappers), 'Please run update.php!'); + return $stream_wrappers; + } + /** * Builds a lookup table: file extension to CDN domain(s). * @@ -94,10 +108,10 @@ class CdnSettings { * more conditions besides extensions are added. For now, KISS. */ protected function buildLookupTable(array $mapping) { + assert(!\Drupal::hasContainer() || \Drupal::service('config.typed')->get('cdn.settings')->validate()->count() === 0, 'There are validation errors for the "cdn.settings" configuration.'); $lookup_table = []; if ($mapping['type'] === 'simple') { $domain = $mapping['domain']; - assert(CdnSettings::isValidCdnDomain($domain), "The provided domain $domain is not valid. Provide a host like 'cdn.com' or 'cdn.example.com'. IP addresses and ports are also allowed."); if (empty($mapping['conditions'])) { $lookup_table['*'] = $domain; } @@ -125,7 +139,6 @@ class CdnSettings { $fallback_domain = NULL; if (isset($mapping['fallback_domain'])) { $fallback_domain = $mapping['fallback_domain']; - assert(CdnSettings::isValidCdnDomain($fallback_domain), "The provided fallback domain $fallback_domain is not valid. Provide a host like 'cdn.com' or 'cdn.example.com'. IP addresses and ports are also allowed."); $lookup_table['*'] = $fallback_domain; } for ($i = 0; $i < count($mapping['domains']); $i++) { @@ -140,9 +153,6 @@ class CdnSettings { throw new ConfigValueException('It does not make sense to apply auto-balancing to all files, regardless of extension.'); } $domains = $mapping['domains']; - foreach ($domains as $domain) { - assert(CdnSettings::isValidCdnDomain($domain), "The provided domain $domain is not valid. Provide a host like 'cdn.com' or 'cdn.example.com'. IP addresses and ports are also allowed."); - } foreach ($mapping['conditions']['extensions'] as $extension) { $lookup_table[$extension] = $domains; } @@ -154,25 +164,46 @@ class CdnSettings { } /** - * Validates the given CDN domain. - * - * @param string $domain - * A domain as expected by the CDN module. In fact, an "authority" as - * defined in RFC3986. An authority consists of host, optional userinfo and - * optional port. The host can be an IP address or registered domain name. + * Maps a URI to a CDN domain. * - * @return bool + * @param string $uri + * The URI to map. * - * @see https://tools.ietf.org/html/rfc3986#section-3.2 - * @see ../config/schema/cdn.data_types.schema.yml + * @return string|bool + * The mapped domain, or FALSE if it could not be matched. */ - public static function isValidCdnDomain($domain) { - // Add a scheme so that we have a parseable URL. - $url = 'https://' . $domain; - $components = parse_url($url); + public function getCdnDomain($uri) { + // Extension-specific mapping. + $file_extension = Unicode::strtolower(pathinfo($uri, PATHINFO_EXTENSION)); + $lookup_table = $this->getLookupTable(); + if (isset($lookup_table[$file_extension])) { + $key = $file_extension; + } + // Generic or fallback mapping. + elseif (isset($lookup_table['*'])) { + $key = '*'; + } + // No mapping. + else { + return FALSE; + } + + $result = $lookup_table[$key]; - $forbidden_components = ['path', 'query', 'fragment']; - return $components === FALSE ? FALSE : empty(array_intersect($forbidden_components, array_keys($components))); + if ($result === FALSE) { + return FALSE; + } + // If there are multiple results, pick one using consistent hashing: ensure + // the same file is always served from the same CDN domain. + elseif (is_array($result)) { + $filename = basename($uri); + $hash = hexdec(substr(md5($filename), 0, 5)); + $cdn_domain = $result[$hash % count($result)]; + } + else { + $cdn_domain = $result; + } + return $cdn_domain; } } diff --git a/src/EventSubscriber/ConfigSubscriber.php b/src/EventSubscriber/ConfigSubscriber.php index cded4ce..3c01221 100644 --- a/src/EventSubscriber/ConfigSubscriber.php +++ b/src/EventSubscriber/ConfigSubscriber.php @@ -2,9 +2,12 @@ namespace Drupal\cdn\EventSubscriber; +use Drupal\Component\Render\PlainTextOutput; use Drupal\Core\Cache\CacheTagsInvalidatorInterface; +use Drupal\Core\Config\Config; use Drupal\Core\Config\ConfigCrudEvent; use Drupal\Core\Config\ConfigEvents; +use Drupal\Core\Config\TypedConfigManagerInterface; use Drupal\Core\DrupalKernelInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -20,6 +23,13 @@ class ConfigSubscriber implements EventSubscriberInterface { */ protected $cacheTagsInvalidator; + /** + * The typed config manager. + * + * @var \Drupal\Core\Config\TypedConfigManagerInterface + */ + protected $typedConfigManager; + /** * The Drupal kernel. * @@ -32,11 +42,14 @@ class ConfigSubscriber implements EventSubscriberInterface { * * @param \Drupal\Core\Cache\CacheTagsInvalidatorInterface $cache_tags_invalidator * The cache tags invalidator. + * @param \Drupal\Core\Config\TypedConfigManagerInterface $typed_config_manager + * The typed config manager. * @param \Drupal\Core\DrupalKernelInterface $drupal_kernel * The Drupal kernel. */ - public function __construct(CacheTagsInvalidatorInterface $cache_tags_invalidator, DrupalKernelInterface $drupal_kernel) { + public function __construct(CacheTagsInvalidatorInterface $cache_tags_invalidator, TypedConfigManagerInterface $typed_config_manager, DrupalKernelInterface $drupal_kernel) { $this->cacheTagsInvalidator = $cache_tags_invalidator; + $this->typedConfigManager = $typed_config_manager; $this->drupalKernel = $drupal_kernel; } @@ -53,6 +66,8 @@ class ConfigSubscriber implements EventSubscriberInterface { 'rendered', ]); + $this->validate($event->getConfig()); + // Rebuild the container whenever the 'status' configuration changes. // @see \Drupal\cdn\CdnServiceProvider if ($event->isChanged('status')) { @@ -61,6 +76,27 @@ class ConfigSubscriber implements EventSubscriberInterface { } } + /** + * Validates the given config. + * + * @param \Drupal\Core\Config\Config $config + * The CDN settings configuration to validate. + * + * @throws \DomainException + * When invalid CDN settings were saved. + */ + protected function validate(Config $config) { + $typed_updated_config = $this->typedConfigManager->createFromNameAndData('cdn.settings', $config->getRawData()); + $violations = $typed_updated_config->validate(); + if ($violations->count() > 0) { + $message = "Invalid CDN settings.\n"; + foreach ($violations as $violation) { + $message .= $violation->getPropertyPath() . ': ' . PlainTextOutput::renderFromHtml($violation->getMessage()) . "\n"; + } + throw new \DomainException($message); + } + } + /** * {@inheritdoc} */ diff --git a/src/File/FileUrlGenerator.php b/src/File/FileUrlGenerator.php index 13a8cf3..92bc654 100644 --- a/src/File/FileUrlGenerator.php +++ b/src/File/FileUrlGenerator.php @@ -5,10 +5,10 @@ namespace Drupal\cdn\File; use Drupal\cdn\CdnSettings; use Drupal\Component\Utility\Crypt; use Drupal\Component\Utility\Unicode; +use Drupal\Component\Utility\UrlHelper; use Drupal\Core\File\FileSystemInterface; use Drupal\Core\PrivateKey; use Drupal\Core\Site\Settings; -use Drupal\Core\StreamWrapper\StreamWrapperInterface; use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface; use Symfony\Component\HttpFoundation\RequestStack; @@ -19,6 +19,8 @@ use Symfony\Component\HttpFoundation\RequestStack; */ class FileUrlGenerator { + const RELATIVE = ':relative:'; + /** * The app root. * @@ -111,8 +113,7 @@ class FileUrlGenerator { return FALSE; } - $relative_url = $this->getRelativeUrl($uri); - if ($relative_url === FALSE) { + if (!$this->canServe($uri)) { return FALSE; } @@ -123,23 +124,32 @@ class FileUrlGenerator { // When farfuture is enabled, rewrite the file URL to let Drupal serve the // file with optimal headers. Only possible if the file exists. - if ($this->settings->farfutureIsEnabled()) { + if (!$scheme = $this->fileSystem->uriScheme($uri)) { + $scheme = self::RELATIVE; // A relative URL for a file contains '%20' instead of spaces. A relative // file path contains spaces. - $relative_file_path = rawurldecode($relative_url); - $absolute_file_path = $this->root . $relative_file_path; - if (file_exists($absolute_file_path)) { - // We do the filemtime() call separately, because a failed filemtime() - // will cause a PHP warning to be written to the log, which would remove - // any performance gain achieved by removing the file_exists() call. - $mtime = filemtime($absolute_file_path); - - // Generate a security token. Ensures that users can not request any - // file they want by manipulating the URL (they could otherwise request - // settings.php for example). See https://www.drupal.org/node/1441502. - $calculated_token = Crypt::hmacBase64($mtime . $relative_url, $this->privateKey->get() . Settings::getHashSalt()); - return '//' . $cdn_domain . $this->getBasePath() . '/cdn/farfuture/' . $calculated_token . '/' . $mtime . $relative_url; - } + $filePath = $relative_url = '/' . $uri; + $realFile = $this->root . rawurldecode($relative_url);; + } + else { + $fileUri = $realFile = $uri; + $filePath = '/' . substr($fileUri, strlen($scheme . '://')); + $relative_url = str_replace($this->requestStack->getCurrentRequest()->getSchemeAndHttpHost() . $this->getBasePath(), '', $this->streamWrapperManager->getViaUri($uri)->getExternalUrl()); + } + + // When farfuture is enabled, rewrite the file URL to let Drupal serve the + // file with optimal headers. Only possible if the file exists. + if ($this->settings->farfutureIsEnabled() && file_exists($realFile)) { + // We do the filemtime() call separately, because a failed filemtime() + // will cause a PHP warning to be written to the log, which would remove + // any performance gain achieved by removing the file_exists() call. + $mtime = filemtime($realFile); + + // Generate a security token. Ensures that users can not request any + // file they want by manipulating the URL (they could otherwise request + // settings.php for example). See https://www.drupal.org/node/1441502. + $calculated_token = Crypt::hmacBase64($mtime . $scheme . UrlHelper::encodePath($filePath), $this->privateKey->get() . Settings::getHashSalt()); + return '//' . $cdn_domain . $this->getBasePath() . '/cdn/ff/' . $calculated_token . '/' . $mtime . '/' . $scheme . $filePath; } return '//' . $cdn_domain . $this->getBasePath() . $relative_url; @@ -190,40 +200,31 @@ class FileUrlGenerator { } /** - * Gets the relative URL for files that are shipped or in a local stream. + * Determines if a URI can/should be served by CDN. * * @param string $uri * The URI to a file for which we need a CDN URL, or the path to a shipped * file. * - * @return bool|string - * Returns FALSE if the URI is not for a shipped file or in a local stream. - * Otherwise, returns the relative URL. + * @return bool + * Returns FALSE if the URI is not for a shipped file or in an eligible + * stream. TRUE otherwise. */ - protected function getRelativeUrl($uri) { + protected function canServe($uri) { $scheme = $this->fileSystem->uriScheme($uri); + // Allow additional stream wrappers to be served via CDN. + $streamWrapperTypes = $this->settings->getStreamWrappers(); // If the URI is absolute — HTTP(S) or otherwise — return early, except if - // it's an absolute URI using a local stream wrapper scheme. - if ($scheme && !isset($this->streamWrapperManager->getWrappers(StreamWrapperInterface::LOCAL)[$scheme])) { + // it's an absolute URI using an approved stream wrapper type. + if ($scheme && !in_array($scheme, $streamWrapperTypes)) { return FALSE; } // If the URI is protocol-relative, return early. elseif (Unicode::substr($uri, 0, 2) === '//') { return FALSE; } - // The private:// stream wrapper is explicitly not supported. - elseif ($scheme === 'private') { - return FALSE; - } - - $request = $this->requestStack->getCurrentRequest(); - - return $scheme - // Local stream wrapper. - ? str_replace($request->getSchemeAndHttpHost() . $this->getBasePath(), '', $this->streamWrapperManager->getViaUri($uri)->getExternalUrl()) - // Shipped file. - : '/' . $uri; + return TRUE; } /** diff --git a/src/PathProcessor/CdnFarfuturePathProcessor.php b/src/PathProcessor/CdnFarfuturePathProcessor.php index d793834..b44702f 100644 --- a/src/PathProcessor/CdnFarfuturePathProcessor.php +++ b/src/PathProcessor/CdnFarfuturePathProcessor.php @@ -2,6 +2,7 @@ namespace Drupal\cdn\PathProcessor; +use Drupal\cdn\File\FileUrlGenerator; use Drupal\Component\Utility\UrlHelper; use Drupal\Core\PathProcessor\InboundPathProcessorInterface; use Symfony\Component\HttpFoundation\Request; @@ -12,6 +13,9 @@ use Symfony\Component\HttpFoundation\Request; * As the route system does not allow arbitrary amount of parameters convert * the file path to a query parameter on the request. * + * Also normalizes legacy far-future URLs generated prior to + * https://www.drupal.org/node/2870435 + * * @see \Drupal\image\PathProcessor\PathProcessorImageStyles */ class CdnFarfuturePathProcessor implements InboundPathProcessorInterface { @@ -20,19 +24,54 @@ class CdnFarfuturePathProcessor implements InboundPathProcessorInterface { * {@inheritdoc} */ public function processInbound($path, Request $request) { - if (strpos($path, '/cdn/farfuture/') !== 0) { - return $path; + if (strpos($path, '/cdn/farfuture/') === 0) { + return $this->processDeprecatedFarFuture($path, $request); + } + if (strpos($path, '/cdn/ff/') === 0) { + return $this->processFarFuture($path, $request); } + return $path; + } - // Parse the security token, mtime and root-relative file URL. + /** + * Process the path for the far future controller. + * + * @param string $path + * The path. + * @param \Symfony\Component\HttpFoundation\Request $request + * The request. + * + * @return string The processed path. + */ + protected function processFarFuture($path, Request $request) { + // Parse the security token, mtime, scheme and root-relative file URL. + $tail = substr($path, strlen('/cdn/ff/')); + list($security_token, $mtime, $scheme, $relative_file_url) = explode('/', $tail, 4); + $returnPath = "/cdn/ff/$security_token/$mtime/$scheme"; + // Set the root-relative file URL as query parameter. + $request->query->set('relative_file_url', '/' . UrlHelper::encodePath($relative_file_url)); + // Return the same path, but without the trailing file. + return $returnPath; + } + + /** + * Process the path for the deprecated far future controller. + * + * @param string $path + * The path. + * @param \Symfony\Component\HttpFoundation\Request $request + * The request. + * + * @return string The processed path. + */ + protected function processDeprecatedFarFuture($path, Request $request) { $tail = substr($path, strlen('/cdn/farfuture/')); list($security_token, $mtime, $root_relative_file_url) = explode('/', $tail, 3); - + $returnPath = "/cdn/farfuture/$security_token/$mtime"; // Set the root-relative file URL as query parameter. $request->query->set('root_relative_file_url', '/' . UrlHelper::encodePath($root_relative_file_url)); - // Return the same path, but without the trailing file. - return "/cdn/farfuture/$security_token/$mtime"; + return $returnPath; } } diff --git a/src/Plugin/Validation/Constraint/CdnDomainConstraint.php b/src/Plugin/Validation/Constraint/CdnDomainConstraint.php new file mode 100644 index 0000000..e81f16e --- /dev/null +++ b/src/Plugin/Validation/Constraint/CdnDomainConstraint.php @@ -0,0 +1,24 @@ +cdn.com or cdn.example.com. IP addresses and ports are also allowed.'; + +} diff --git a/src/Plugin/Validation/Constraint/CdnDomainConstraintValidator.php b/src/Plugin/Validation/Constraint/CdnDomainConstraintValidator.php new file mode 100644 index 0000000..ce282b1 --- /dev/null +++ b/src/Plugin/Validation/Constraint/CdnDomainConstraintValidator.php @@ -0,0 +1,51 @@ +context->buildViolation($constraint->message) + ->setParameter('%domain', $domain) + ->setInvalidValue($domain) + ->addViolation(); + } + } + + /** + * Validates the given CDN domain. + * + * @param string $domain + * A domain as expected by the CDN module: an "authority" in RFC3986. + * + * @return bool + */ + protected static function isValidCdnDomain($domain) { + // Add a scheme so that we have a parseable URL. + $url = 'https://' . $domain; + $components = parse_url($url); + + $forbidden_components = ['path', 'query', 'fragment']; + return $components === FALSE ? FALSE : empty(array_intersect($forbidden_components, array_keys($components))); + } + +} diff --git a/tests/src/Functional/CdnIntegrationTest.php b/tests/src/Functional/CdnIntegrationTest.php index 009230e..7638c0e 100644 --- a/tests/src/Functional/CdnIntegrationTest.php +++ b/tests/src/Functional/CdnIntegrationTest.php @@ -116,7 +116,7 @@ class CdnIntegrationTest extends BrowserTestBase { $this->drupalGet(''); $this->assertSame('MISS', $session->getResponseHeader('X-Drupal-Cache'), 'Changing CDN settings causes Page Cache miss: setting changes have immediate effect.'); $href = $this->cssSelect('link[rel=stylesheet]')[0]->getAttribute('href'); - $regexp = '#//cdn.example.com' . base_path() . 'cdn/farfuture/[a-zA-Z0-9_-]{43}/[0-9]{10}/' . $this->siteDirectory . '/files/css/css_[a-zA-Z0-9_-]{43}\.css\?[a-z0-9]{6}#'; + $regexp = '#//cdn.example.com' . base_path() . 'cdn/ff/[a-zA-Z0-9_-]{43}/[0-9]{10}/public/css/css_[a-zA-Z0-9_-]{43}\.css\?[a-z0-9]{6}#'; $this->assertSame(1, preg_match($regexp, $href)); $this->assertCssFileUsesRootRelativeUrl($this->baseUrl . str_replace('//cdn.example.com', '', $href)); } @@ -177,9 +177,9 @@ class CdnIntegrationTest extends BrowserTestBase { } /** - * Tests that the cdn.farfuture.download route/controller work as expected. + * Tests the legacy far future path. */ - public function testFarfuture() { + public function testLegacyFarfuture() { $druplicon_png_mtime = filemtime('public://druplicon ❤️.png'); $druplicon_png_security_token = Crypt::hmacBase64($druplicon_png_mtime . '/' . $this->siteDirectory . '/files/' . UrlHelper::encodePath('druplicon ❤️.png'), \Drupal::service('private_key')->get() . Settings::getHashSalt()); @@ -195,4 +195,25 @@ class CdnIntegrationTest extends BrowserTestBase { $this->assertSession()->statusCodeEquals(403); } + /** + * Tests that the cdn.farfuture.download route/controller work as expected. + */ + public function testFarfuture() { + $druplicon_png_mtime = filemtime('public://druplicon ❤️.png'); + $druplicon_png_security_token = Crypt::hmacBase64($druplicon_png_mtime . 'public' . UrlHelper::encodePath('/druplicon ❤️.png'), \Drupal::service('private_key')->get() . Settings::getHashSalt()); + $druplicon_png_relative_security_token = Crypt::hmacBase64($druplicon_png_mtime . ':relative:' . UrlHelper::encodePath('/' . $this->siteDirectory . '/files/druplicon ❤️.png'), \Drupal::service('private_key')->get() . Settings::getHashSalt()); + $this->drupalGet('/cdn/ff/' . $druplicon_png_security_token . '/' . $druplicon_png_mtime . '/public/druplicon ❤️.png'); + $this->assertSession()->statusCodeEquals(200); + $this->drupalGet('/cdn/ff/' . $druplicon_png_relative_security_token . '/' . $druplicon_png_mtime . '/:relative:/' . $this->siteDirectory . '/files/druplicon ❤️.png'); + $this->assertSession()->statusCodeEquals(200); + // Assert presence of headers that \Drupal\cdn\CdnFarfutureController sets. + $this->assertSame('Wed, 20 Jan 1988 04:20:42 GMT', $this->getSession()->getResponseHeader('Last-Modified')); + // Assert presence of headers that Symfony's BinaryFileResponse sets. + $this->assertSame('bytes', $this->getSession()->getResponseHeader('Accept-Ranges')); + + // Any chance to the security token should cause a 403. + $this->drupalGet('/cdn/ff/' . substr($druplicon_png_security_token, 1) . '/' . $druplicon_png_mtime . '/public/druplicon ❤️.png'); + $this->assertSession()->statusCodeEquals(403); + } + } diff --git a/tests/src/Unit/CdnSettingsTest.php b/tests/src/Unit/CdnSettingsTest.php index c247403..2b126e9 100644 --- a/tests/src/Unit/CdnSettingsTest.php +++ b/tests/src/Unit/CdnSettingsTest.php @@ -296,106 +296,6 @@ class CdnSettingsTest extends UnitTestCase { ])->getLookupTable(); } - /** - * @covers ::getLookupTable - */ - public function testAbsoluteUrlAsSimpleDomain() { - $this->setExpectedException(\AssertionError::class, "The provided domain http://cdn.example.com is not valid. Provide a host like 'cdn.com' or 'cdn.example.com'. IP addresses and ports are also allowed."); - $this->createCdnSettings([ - 'status' => TRUE, - 'mapping' => [ - 'type' => 'simple', - 'domain' => 'http://cdn.example.com', - ], - ])->getLookupTable(); - } - - /** - * @covers ::getLookupTable - */ - public function testProtocolRelativeUrlAsSimpleDomain() { - $this->setExpectedException(\AssertionError::class, "The provided domain //cdn.example.com is not valid. Provide a host like 'cdn.com' or 'cdn.example.com'. IP addresses and ports are also allowed."); - $this->createCdnSettings([ - 'status' => TRUE, - 'mapping' => [ - 'type' => 'simple', - 'domain' => '//cdn.example.com', - ], - ])->getLookupTable(); - } - - /** - * @covers ::getLookupTable - */ - public function testAbsoluteUrlAsComplexFallbackDomain() { - $this->setExpectedException(\AssertionError::class, "The provided fallback domain http://cdn.example.com is not valid. Provide a host like 'cdn.com' or 'cdn.example.com'. IP addresses and ports are also allowed."); - $this->createCdnSettings([ - 'status' => TRUE, - 'mapping' => [ - 'type' => 'complex', - 'fallback_domain' => 'http://cdn.example.com', - ], - ])->getLookupTable(); - } - - /** - * @covers ::getLookupTable - */ - public function testProtocolRelativeUrlAsComplexFallbackDomain() { - $this->setExpectedException(\AssertionError::class, "The provided fallback domain //cdn.example.com is not valid. Provide a host like 'cdn.com' or 'cdn.example.com'. IP addresses and ports are also allowed."); - $this->createCdnSettings([ - 'status' => TRUE, - 'mapping' => [ - 'type' => 'complex', - 'fallback_domain' => '//cdn.example.com', - ], - ])->getLookupTable(); - } - - /** - * @covers ::getLookupTable - */ - public function testAbsoluteUrlAsAutobalancedDomain() { - $this->setExpectedException(\AssertionError::class, "The provided domain http://foo.example.com is not valid. Provide a host like 'cdn.com' or 'cdn.example.com'. IP addresses and ports are also allowed."); - $this->createCdnSettings([ - 'status' => TRUE, - 'mapping' => [ - 'type' => 'auto-balanced', - 'domains' => [ - 'http://foo.example.com', - 'http://bar.example.com', - ], - 'conditions' => [ - 'extensions' => [ - 'png', - ], - ], - ], - ])->getLookupTable(); - } - - /** - * @covers ::getLookupTable - */ - public function testProtocolRelativeUrlAsAutobalancedDomain() { - $this->setExpectedException(\AssertionError::class, "The provided domain //foo.example.com is not valid. Provide a host like 'cdn.com' or 'cdn.example.com'. IP addresses and ports are also allowed."); - $this->createCdnSettings([ - 'status' => TRUE, - 'mapping' => [ - 'type' => 'auto-balanced', - 'domains' => [ - '//foo.example.com', - '//bar.example.com', - ], - 'conditions' => [ - 'extensions' => [ - 'png', - ], - ], - ], - ])->getLookupTable(); - } - /** * @covers ::getLookupTable */ diff --git a/tests/src/Unit/File/FileUrlGeneratorTest.php b/tests/src/Unit/File/FileUrlGeneratorTest.php index 40a085d..a505a54 100644 --- a/tests/src/Unit/File/FileUrlGeneratorTest.php +++ b/tests/src/Unit/File/FileUrlGeneratorTest.php @@ -9,6 +9,7 @@ use Drupal\Component\Utility\UrlHelper; use Drupal\Core\File\FileSystem; use Drupal\Core\PrivateKey; use Drupal\Core\Site\Settings; +use Drupal\Core\StreamWrapper\LocalStream; use Drupal\Core\StreamWrapper\PublicStream; use Drupal\Core\StreamWrapper\StreamWrapperInterface; use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface; @@ -67,10 +68,11 @@ class FileUrlGeneratorTest extends UnitTestCase { ], ], ], - 'farfuture' => [ - 'status' => FALSE, - ], ], + 'farfuture' => [ + 'status' => FALSE, + ], + 'stream_wrappers' => ['public'], ]); $this->assertSame($expected_result, $gen->generate($uri)); } @@ -133,6 +135,12 @@ class FileUrlGeneratorTest extends UnitTestCase { 'farfuture' => [ 'status' => TRUE, ], + // File is used here generically to test a stream wrapper that is not + // shipped with Drupal, but is natively supported by PHP. + // @see \Drupal\cdn\File\FileUrlGenerator::generate(), which uses + // file_exists() and would require actually configuring the stream + // wrapper in the context of the unit test. + 'stream_wrappers' => ['public', 'file'], ]; // Generate file for testing managed file. @@ -146,16 +154,21 @@ class FileUrlGeneratorTest extends UnitTestCase { $gen = $this->createFileUrlGenerator('', $config); $this->assertSame('//cdn.example.com/core/misc/does-not-exist.js', $gen->generate('core/misc/does-not-exist.js')); $drupal_js_mtime = filemtime($this->root . '/core/misc/drupal.js'); - $drupal_js_security_token = Crypt::hmacBase64($drupal_js_mtime . '/core/misc/drupal.js', static::$privateKey . Settings::getHashSalt()); - $this->assertSame('//cdn.example.com/cdn/farfuture/' . $drupal_js_security_token . '/' . $drupal_js_mtime . '/core/misc/drupal.js', $gen->generate('core/misc/drupal.js')); - $llama_jpg_security_token = Crypt::hmacBase64($llama_jpg_mtime . '/sites/default/files/' . UrlHelper::encodePath($llama_jpg_filename), static::$privateKey . Settings::getHashSalt()); - $this->assertSame('//cdn.example.com/cdn/farfuture/' . $llama_jpg_security_token . '/' . $llama_jpg_mtime . '/sites/default/files/' . UrlHelper::encodePath($llama_jpg_filename), $gen->generate('public://' . $llama_jpg_filename)); + $drupal_js_security_token = Crypt::hmacBase64($drupal_js_mtime . ':relative:' . UrlHelper::encodePath('/core/misc/drupal.js'), static::$privateKey . Settings::getHashSalt()); + $this->assertSame('//cdn.example.com/cdn/ff/' . $drupal_js_security_token . '/' . $drupal_js_mtime . '/:relative:/core/misc/drupal.js', $gen->generate('core/misc/drupal.js')); + // Since the public stream wrapper is not available in the unit test, + // and we use file_exists() in the target method, we are using the + // file:// scheme that ships with PHP. This does require + // injecting a leading into the path that we compare against, to match + // the method. + $llama_jpg_security_token = Crypt::hmacBase64($llama_jpg_mtime . 'file' . UrlHelper::encodePath('/' . $llama_jpg_filepath), static::$privateKey . Settings::getHashSalt()); + $this->assertSame('//cdn.example.com/cdn/ff/' . $llama_jpg_security_token . '/' . $llama_jpg_mtime . '/file/' . $llama_jpg_filepath, $gen->generate('file://' . $llama_jpg_filepath)); // In subdir: 1) non-existing file, 2) shipped file, 3) managed file. $gen = $this->createFileUrlGenerator('/subdir', $config); $this->assertSame('//cdn.example.com/subdir/core/misc/does-not-exist.js', $gen->generate('core/misc/does-not-exist.js')); - $this->assertSame('//cdn.example.com/subdir/cdn/farfuture/' . $drupal_js_security_token . '/' . $drupal_js_mtime . '/core/misc/drupal.js', $gen->generate('core/misc/drupal.js')); - $this->assertSame('//cdn.example.com/subdir/cdn/farfuture/' . $llama_jpg_security_token . '/' . $llama_jpg_mtime . '/sites/default/files/' . UrlHelper::encodePath($llama_jpg_filename), $gen->generate('public://' . $llama_jpg_filename)); + $this->assertSame('//cdn.example.com/subdir/cdn/ff/' . $drupal_js_security_token . '/' . $drupal_js_mtime . '/:relative:/core/misc/drupal.js', $gen->generate('core/misc/drupal.js')); + $this->assertSame('//cdn.example.com/subdir/cdn/ff/' . $llama_jpg_security_token . '/' . $llama_jpg_mtime . '/file/' . $llama_jpg_filepath, $gen->generate('file://' . $llama_jpg_filepath)); unlink($llama_jpg_filepath); } @@ -191,9 +204,16 @@ class FileUrlGeneratorTest extends UnitTestCase { ->will(function () use ($base_path, &$current_uri) { return 'http://example.com' . $base_path . '/sites/default/files/' . UrlHelper::encodePath(substr($current_uri, 9)); }); + $file_stream_wrapper = $this->prophesize(LocalStream::class); + $root = $this->root; + $file_stream_wrapper->getExternalUrl() + ->will(function () use ($root, $base_path, &$current_uri) { + // The file:// stream wrapper is only used for testing FF. + return 'http://example.com/inaccessible'; + }); $stream_wrapper_manager = $this->prophesize(StreamWrapperManagerInterface::class); - $stream_wrapper_manager->getWrappers(StreamWrapperInterface::LOCAL) - ->willReturn(['public' => TRUE, 'private' => TRUE]); + $stream_wrapper_manager->getWrappers(StreamWrapperInterface::LOCAL_NORMAL) + ->willReturn(['public' => TRUE]); $stream_wrapper_manager->getViaUri(Argument::that(function ($uri) { return substr($uri, 0, 9) === 'public://'; })) @@ -202,6 +222,14 @@ class FileUrlGeneratorTest extends UnitTestCase { $current_uri = $args[0]; return $s; }); + $stream_wrapper_manager->getViaUri(Argument::that(function ($uri) { + return substr($uri, 0, 7) === 'file://'; + })) + ->will(function ($args) use (&$file_stream_wrapper, &$current_uri) { + $s = $file_stream_wrapper->reveal(); + $current_uri = $args[0]; + return $s; + }); $private_key = $this->prophesize(PrivateKey::class); $private_key->get() ->willReturn(static::$privateKey); @@ -216,7 +244,7 @@ class FileUrlGeneratorTest extends UnitTestCase { $stream_wrapper_manager->reveal(), $request_stack->reveal(), $private_key->reveal(), - new CdnSettings($this->getConfigFactoryStub(['cdn.settings' => $raw_config])) + new CdnSettings($this->getConfigFactoryStub(['cdn.settings' => $raw_config]), $stream_wrapper_manager->reveal()) ); } diff --git a/tests/src/Unit/Plugin/Validation/Constraint/CdnDomainConstraintValidatorTest.php b/tests/src/Unit/Plugin/Validation/Constraint/CdnDomainConstraintValidatorTest.php new file mode 100644 index 0000000..5ecd234 --- /dev/null +++ b/tests/src/Unit/Plugin/Validation/Constraint/CdnDomainConstraintValidatorTest.php @@ -0,0 +1,278 @@ +prophesize(ConstraintViolationBuilderInterface::class); + $constraint_violation_builder->setParameter('%domain', $value) + ->willReturn($constraint_violation_builder->reveal()); + $constraint_violation_builder->setInvalidValue($value) + ->willReturn($constraint_violation_builder->reveal()); + $constraint_violation_builder->addViolation() + ->willReturn($constraint_violation_builder->reveal()); + if ($valid) { + $constraint_violation_builder->addViolation()->shouldNotBeCalled(); + } + else { + $constraint_violation_builder->addViolation()->shouldBeCalled(); + } + $context = $this->prophesize(ExecutionContextInterface::class); + $context->buildViolation(Argument::type('string')) + ->willReturn($constraint_violation_builder->reveal()); + + $constraint = new CdnDomainConstraint(); + + $validate = new CdnDomainConstraintValidator(); + $validate->initialize($context->reveal()); + $validate->validate($value, $constraint); + } + + public function provideTestValidate() { + $data = []; + + $data['NULL is allowed because this is the initial value when installing the CDN module'] = [NULL, TRUE]; + + // Host = domain. + $data['host (domain) '] = ['cdn', TRUE]; + $data['userinfo + host (domain)'] = ['user:pass@cdn', TRUE]; + $data['host (domain) + port'] = ['cdn:1988', TRUE]; + $data['userinfo + host (domain) + port'] = ['user:pass@cdn:1988', TRUE]; + + $data['host (domain) + path'] = ['cdn/foo/bar', FALSE]; + $data['host (domain) + query'] = ['cdn?foo=bar', FALSE]; + $data['host (domain) + fragment'] = ['cdn#foobar', FALSE]; + $data['host (domain) + path + query'] = ['cdn/foo/bar?foo=bar', FALSE]; + $data['host (domain) + path + fragment'] = ['cdn/foo/bar#foobar', FALSE]; + $data['host (domain) + path + query + fragment'] = ['cdn/foo/bar?foo=bar#foobar', FALSE]; + $data['host (domain) + query + fragment'] = ['cdn/foo/bar?foo=bar#foobar', FALSE]; + + $data['scheme-relative + host (domain)'] = ['//cdn', FALSE]; + + $data['scheme + host (domain)'] = ['https://cdn', FALSE]; + $data['scheme + host (domain) + path'] = ['https://cdn/foobar', FALSE]; + $data['scheme + host (domain) + query'] = ['https://cdn?foo=bar', FALSE]; + $data['scheme + host (domain) + fragment'] = ['https://cdn#foobar', FALSE]; + $data['scheme + host (domain) + path + query'] = ['https://cdn/foo/bar?foo=bar', FALSE]; + $data['scheme + host (domain) + path + fragment'] = ['https://cdn/foo/bar#foobar', FALSE]; + $data['scheme + host (domain) + path + query + fragment'] = ['https://cdn/foo/bar?foo=bar#foobar', FALSE]; + $data['scheme + host (domain) + query + fragment'] = ['https://cdn?foo=bar#foobar', FALSE]; + + $data['userinfo + host (domain) + path'] = ['user:pass@cdn/foo/bar', FALSE]; + $data['userinfo + host (domain) + query'] = ['user:pass@cdn?foo=bar', FALSE]; + $data['userinfo + host (domain) + fragment'] = ['user:pass@cdn#foobar', FALSE]; + $data['userinfo + host (domain) + path + query'] = ['user:pass@cdn/foo/bar?foo=bar', FALSE]; + $data['userinfo + host (domain) + path + fragment'] = ['user:pass@cdn/foo/bar#foobar', FALSE]; + $data['userinfo + host (domain) + path + query + fragment'] = ['user:pass@cdn/foo/bar?foo=bar#foobar', FALSE]; + $data['userinfo + host (domain) + query + fragment'] = ['user:pass@cdn/foo/bar?foo=bar#foobar', FALSE]; + + $data['scheme + userinfo + host (domain)'] = ['https://user:pass@cdn', FALSE]; + $data['scheme + userinfo + host (domain) + path'] = ['https://user:pass@cdn/foobar', FALSE]; + $data['scheme + userinfo + host (domain) + query'] = ['https://user:pass@cdn?foo=bar', FALSE]; + $data['scheme + userinfo + host (domain) + fragment'] = ['https://user:pass@cdn#foobar', FALSE]; + $data['scheme + userinfo + host (domain) + path + query'] = ['https://user:pass@cdn/foo/bar?foo=bar', FALSE]; + $data['scheme + userinfo + host (domain) + path + fragment'] = ['https://user:pass@cdn/foo/bar#foobar', FALSE]; + $data['scheme + userinfo + host (domain) + path + query + fragment'] = ['https://user:pass@cdn/foo/bar?foo=bar#foobar', FALSE]; + $data['scheme + userinfo + host (domain) + query + fragment'] = ['https://user:pass@cdn?foo=bar#foobar', FALSE]; + + $data['host (domain) + port + path'] = ['cdn:1988/foo/bar', FALSE]; + $data['host (domain) + port + query'] = ['cdn:1988?foo=bar', FALSE]; + $data['host (domain) + port + fragment'] = ['cdn:1988#foobar', FALSE]; + $data['host (domain) + port + path + query'] = ['cdn:1988/foo/bar?foo=bar', FALSE]; + $data['host (domain) + port + path + fragment'] = ['cdn:1988/foo/bar#foobar', FALSE]; + $data['host (domain) + port + path + query + fragment'] = ['cdn:1988/foo/bar?foo=bar#foobar', FALSE]; + $data['host (domain) + port + query + fragment'] = ['cdn:1988/foo/bar?foo=bar#foobar', FALSE]; + + $data['scheme + host (domain) + port + path'] = ['https://cdn:1988/foo/bar', FALSE]; + $data['scheme + host (domain) + port + query'] = ['https://cdn:1988?foo=bar', FALSE]; + $data['scheme + host (domain) + port + fragment'] = ['https://cdn:1988#foobar', FALSE]; + $data['scheme + host (domain) + port + path + query'] = ['https://cdn:1988/foo/bar?foo=bar', FALSE]; + $data['scheme + host (domain) + port + path + fragment'] = ['https://cdn:1988/foo/bar#foobar', FALSE]; + $data['scheme + host (domain) + port + path + query + fragment'] = ['https://cdn:1988/foo/bar?foo=bar#foobar', FALSE]; + $data['scheme + host (domain) + port + query + fragment'] = ['https://cdn:1988/foo/bar?foo=bar#foobar', FALSE]; + + $data['userinfo + host (domain) + port + path'] = ['user:pass@cdn:1988/foo/bar', FALSE]; + $data['userinfo + host (domain) + port + query'] = ['user:pass@cdn:1988?foo=bar', FALSE]; + $data['userinfo + host (domain) + port + fragment'] = ['user:pass@cdn:1988#foobar', FALSE]; + $data['userinfo + host (domain) + port + path + query'] = ['user:pass@cdn:1988/foo/bar?foo=bar', FALSE]; + $data['userinfo + host (domain) + port + path + fragment'] = ['user:pass@cdn:1988/foo/bar#foobar', FALSE]; + $data['userinfo + host (domain) + port + path + query + fragment'] = ['user:pass@cdn:1988/foo/bar?foo=bar#foobar', FALSE]; + $data['userinfo + host (domain) + port + query + fragment'] = ['user:pass@cdn:1988/foo/bar?foo=bar#foobar', FALSE]; + + $data['scheme + userinfo + host (domain) + port + path'] = ['https://user:pass@cdn:1988/foo/bar', FALSE]; + $data['scheme + userinfo + host (domain) + port + query'] = ['https://user:pass@cdn:1988?foo=bar', FALSE]; + $data['scheme + userinfo + host (domain) + port + fragment'] = ['https://user:pass@cdn:1988#foobar', FALSE]; + $data['scheme + userinfo + host (domain) + port + path + query'] = ['https://user:pass@cdn:1988/foo/bar?foo=bar', FALSE]; + $data['scheme + userinfo + host (domain) + port + path + fragment'] = ['https://user:pass@cdn:1988/foo/bar#foobar', FALSE]; + $data['scheme + userinfo + host (domain) + port + path + query + fragment'] = ['https://user:pass@cdn:1988/foo/bar?foo=bar#foobar', FALSE]; + $data['scheme + userinfo + host (domain) + port + query + fragment'] = ['https://user:pass@cdn:1988/foo/bar?foo=bar#foobar', FALSE]; + + // Host = IPv4. + $data['host (IPv4) '] = ['20.01.19.88', TRUE]; + $data['userinfo + host (IPv4)'] = ['user:pass@20.01.19.88', TRUE]; + $data['host (IPv4) + port'] = ['20.01.19.88:1988', TRUE]; + $data['userinfo + host (IPv4) + port'] = ['user:pass@20.01.19.88:1988', TRUE]; + + $data['host (IPv4) + path'] = ['20.01.19.88/foo/bar', FALSE]; + $data['host (IPv4) + query'] = ['20.01.19.88?foo=bar', FALSE]; + $data['host (IPv4) + fragment'] = ['20.01.19.88#foobar', FALSE]; + $data['host (IPv4) + path + query'] = ['20.01.19.88/foo/bar?foo=bar', FALSE]; + $data['host (IPv4) + path + fragment'] = ['20.01.19.88/foo/bar#foobar', FALSE]; + $data['host (IPv4) + path + query + fragment'] = ['20.01.19.88/foo/bar?foo=bar#foobar', FALSE]; + $data['host (IPv4) + query + fragment'] = ['20.01.19.88/foo/bar?foo=bar#foobar', FALSE]; + + $data['scheme-relative + host (IPv4)'] = ['//20.01.19.88', FALSE]; + + $data['scheme + host (IPv4)'] = ['https://20.01.19.88', FALSE]; + $data['scheme + host (IPv4) + path'] = ['https://20.01.19.88/foobar', FALSE]; + $data['scheme + host (IPv4) + query'] = ['https://20.01.19.88?foo=bar', FALSE]; + $data['scheme + host (IPv4) + fragment'] = ['https://20.01.19.88#foobar', FALSE]; + $data['scheme + host (IPv4) + path + query'] = ['https://20.01.19.88/foo/bar?foo=bar', FALSE]; + $data['scheme + host (IPv4) + path + fragment'] = ['https://20.01.19.88/foo/bar#foobar', FALSE]; + $data['scheme + host (IPv4) + path + query + fragment'] = ['https://20.01.19.88/foo/bar?foo=bar#foobar', FALSE]; + $data['scheme + host (IPv4) + query + fragment'] = ['https://20.01.19.88?foo=bar#foobar', FALSE]; + + $data['userinfo + host (IPv4) + path'] = ['user:pass@20.01.19.88/foo/bar', FALSE]; + $data['userinfo + host (IPv4) + query'] = ['user:pass@20.01.19.88?foo=bar', FALSE]; + $data['userinfo + host (IPv4) + fragment'] = ['user:pass@20.01.19.88#foobar', FALSE]; + $data['userinfo + host (IPv4) + path + query'] = ['user:pass@20.01.19.88/foo/bar?foo=bar', FALSE]; + $data['userinfo + host (IPv4) + path + fragment'] = ['user:pass@20.01.19.88/foo/bar#foobar', FALSE]; + $data['userinfo + host (IPv4) + path + query + fragment'] = ['user:pass@20.01.19.88/foo/bar?foo=bar#foobar', FALSE]; + $data['userinfo + host (IPv4) + query + fragment'] = ['user:pass@20.01.19.88/foo/bar?foo=bar#foobar', FALSE]; + + $data['scheme + userinfo + host (IPv4)'] = ['https://user:pass@20.01.19.88', FALSE]; + $data['scheme + userinfo + host (IPv4) + path'] = ['https://user:pass@20.01.19.88/foobar', FALSE]; + $data['scheme + userinfo + host (IPv4) + query'] = ['https://user:pass@20.01.19.88?foo=bar', FALSE]; + $data['scheme + userinfo + host (IPv4) + fragment'] = ['https://user:pass@20.01.19.88#foobar', FALSE]; + $data['scheme + userinfo + host (IPv4) + path + query'] = ['https://user:pass@20.01.19.88/foo/bar?foo=bar', FALSE]; + $data['scheme + userinfo + host (IPv4) + path + fragment'] = ['https://user:pass@20.01.19.88/foo/bar#foobar', FALSE]; + $data['scheme + userinfo + host (IPv4) + path + query + fragment'] = ['https://user:pass@20.01.19.88/foo/bar?foo=bar#foobar', FALSE]; + $data['scheme + userinfo + host (IPv4) + query + fragment'] = ['https://user:pass@20.01.19.88?foo=bar#foobar', FALSE]; + + $data['host (IPv4) + port + path'] = ['20.01.19.88:1988/foo/bar', FALSE]; + $data['host (IPv4) + port + query'] = ['20.01.19.88:1988?foo=bar', FALSE]; + $data['host (IPv4) + port + fragment'] = ['20.01.19.88:1988#foobar', FALSE]; + $data['host (IPv4) + port + path + query'] = ['20.01.19.88:1988/foo/bar?foo=bar', FALSE]; + $data['host (IPv4) + port + path + fragment'] = ['20.01.19.88:1988/foo/bar#foobar', FALSE]; + $data['host (IPv4) + port + path + query + fragment'] = ['20.01.19.88:1988/foo/bar?foo=bar#foobar', FALSE]; + $data['host (IPv4) + port + query + fragment'] = ['20.01.19.88:1988/foo/bar?foo=bar#foobar', FALSE]; + + $data['scheme + host (IPv4) + port + path'] = ['https://20.01.19.88:1988/foo/bar', FALSE]; + $data['scheme + host (IPv4) + port + query'] = ['https://20.01.19.88:1988?foo=bar', FALSE]; + $data['scheme + host (IPv4) + port + fragment'] = ['https://20.01.19.88:1988#foobar', FALSE]; + $data['scheme + host (IPv4) + port + path + query'] = ['https://20.01.19.88:1988/foo/bar?foo=bar', FALSE]; + $data['scheme + host (IPv4) + port + path + fragment'] = ['https://20.01.19.88:1988/foo/bar#foobar', FALSE]; + $data['scheme + host (IPv4) + port + path + query + fragment'] = ['https://20.01.19.88:1988/foo/bar?foo=bar#foobar', FALSE]; + $data['scheme + host (IPv4) + port + query + fragment'] = ['https://20.01.19.88:1988/foo/bar?foo=bar#foobar', FALSE]; + + $data['userinfo + host (IPv4) + port + path'] = ['user:pass@20.01.19.88:1988/foo/bar', FALSE]; + $data['userinfo + host (IPv4) + port + query'] = ['user:pass@20.01.19.88:1988?foo=bar', FALSE]; + $data['userinfo + host (IPv4) + port + fragment'] = ['user:pass@20.01.19.88:1988#foobar', FALSE]; + $data['userinfo + host (IPv4) + port + path + query'] = ['user:pass@20.01.19.88:1988/foo/bar?foo=bar', FALSE]; + $data['userinfo + host (IPv4) + port + path + fragment'] = ['user:pass@20.01.19.88:1988/foo/bar#foobar', FALSE]; + $data['userinfo + host (IPv4) + port + path + query + fragment'] = ['user:pass@20.01.19.88:1988/foo/bar?foo=bar#foobar', FALSE]; + $data['userinfo + host (IPv4) + port + query + fragment'] = ['user:pass@20.01.19.88:1988/foo/bar?foo=bar#foobar', FALSE]; + + $data['scheme + userinfo + host (IPv4) + port + path'] = ['https://user:pass@20.01.19.88:1988/foo/bar', FALSE]; + $data['scheme + userinfo + host (IPv4) + port + query'] = ['https://user:pass@20.01.19.88:1988?foo=bar', FALSE]; + $data['scheme + userinfo + host (IPv4) + port + fragment'] = ['https://user:pass@20.01.19.88:1988#foobar', FALSE]; + $data['scheme + userinfo + host (IPv4) + port + path + query'] = ['https://user:pass@20.01.19.88:1988/foo/bar?foo=bar', FALSE]; + $data['scheme + userinfo + host (IPv4) + port + path + fragment'] = ['https://user:pass@20.01.19.88:1988/foo/bar#foobar', FALSE]; + $data['scheme + userinfo + host (IPv4) + port + path + query + fragment'] = ['https://user:pass@20.01.19.88:1988/foo/bar?foo=bar#foobar', FALSE]; + $data['scheme + userinfo + host (IPv4) + port + query + fragment'] = ['https://user:pass@20.01.19.88:1988/foo/bar?foo=bar#foobar', FALSE]; + + // Host = IPv6. + $data['host (IPv6) '] = ['2001:db8::ff00:42:8329', TRUE]; + $data['userinfo + host (IPv6)'] = ['user:pass@2001:db8::ff00:42:8329', TRUE]; + $data['host (IPv6) + port'] = ['2001:db8::ff00:42:8329:1988', TRUE]; + $data['userinfo + host (IPv6) + port'] = ['user:pass@2001:db8::ff00:42:8329:1988', TRUE]; + + $data['scheme-relative + host (domain)'] = ['//2001:db8::ff00:42:8329', FALSE]; + + $data['host (IPv6) + path'] = ['2001:db8::ff00:42:8329/foo/bar', FALSE]; + $data['host (IPv6) + query'] = ['2001:db8::ff00:42:8329?foo=bar', FALSE]; + $data['host (IPv6) + fragment'] = ['2001:db8::ff00:42:8329#foobar', FALSE]; + $data['host (IPv6) + path + query'] = ['2001:db8::ff00:42:8329/foo/bar?foo=bar', FALSE]; + $data['host (IPv6) + path + fragment'] = ['2001:db8::ff00:42:8329/foo/bar#foobar', FALSE]; + $data['host (IPv6) + path + query + fragment'] = ['2001:db8::ff00:42:8329/foo/bar?foo=bar#foobar', FALSE]; + $data['host (IPv6) + query + fragment'] = ['2001:db8::ff00:42:8329/foo/bar?foo=bar#foobar', FALSE]; + + $data['scheme + host (IPv6)'] = ['https://2001:db8::ff00:42:8329', FALSE]; + $data['scheme + host (IPv6) + path'] = ['https://2001:db8::ff00:42:8329/foobar', FALSE]; + $data['scheme + host (IPv6) + query'] = ['https://2001:db8::ff00:42:8329?foo=bar', FALSE]; + $data['scheme + host (IPv6) + fragment'] = ['https://2001:db8::ff00:42:8329#foobar', FALSE]; + $data['scheme + host (IPv6) + path + query'] = ['https://2001:db8::ff00:42:8329/foo/bar?foo=bar', FALSE]; + $data['scheme + host (IPv6) + path + fragment'] = ['https://2001:db8::ff00:42:8329/foo/bar#foobar', FALSE]; + $data['scheme + host (IPv6) + path + query + fragment'] = ['https://2001:db8::ff00:42:8329/foo/bar?foo=bar#foobar', FALSE]; + $data['scheme + host (IPv6) + query + fragment'] = ['https://2001:db8::ff00:42:8329?foo=bar#foobar', FALSE]; + + $data['userinfo + host (IPv6) + path'] = ['user:pass@2001:db8::ff00:42:8329/foo/bar', FALSE]; + $data['userinfo + host (IPv6) + query'] = ['user:pass@2001:db8::ff00:42:8329?foo=bar', FALSE]; + $data['userinfo + host (IPv6) + fragment'] = ['user:pass@2001:db8::ff00:42:8329#foobar', FALSE]; + $data['userinfo + host (IPv6) + path + query'] = ['user:pass@2001:db8::ff00:42:8329/foo/bar?foo=bar', FALSE]; + $data['userinfo + host (IPv6) + path + fragment'] = ['user:pass@2001:db8::ff00:42:8329/foo/bar#foobar', FALSE]; + $data['userinfo + host (IPv6) + path + query + fragment'] = ['user:pass@2001:db8::ff00:42:8329/foo/bar?foo=bar#foobar', FALSE]; + $data['userinfo + host (IPv6) + query + fragment'] = ['user:pass@2001:db8::ff00:42:8329/foo/bar?foo=bar#foobar', FALSE]; + + $data['scheme + userinfo + host (IPv6)'] = ['https://user:pass@2001:db8::ff00:42:8329', FALSE]; + $data['scheme + userinfo + host (IPv6) + path'] = ['https://user:pass@2001:db8::ff00:42:8329/foobar', FALSE]; + $data['scheme + userinfo + host (IPv6) + query'] = ['https://user:pass@2001:db8::ff00:42:8329?foo=bar', FALSE]; + $data['scheme + userinfo + host (IPv6) + fragment'] = ['https://user:pass@2001:db8::ff00:42:8329#foobar', FALSE]; + $data['scheme + userinfo + host (IPv6) + path + query'] = ['https://user:pass@2001:db8::ff00:42:8329/foo/bar?foo=bar', FALSE]; + $data['scheme + userinfo + host (IPv6) + path + fragment'] = ['https://user:pass@2001:db8::ff00:42:8329/foo/bar#foobar', FALSE]; + $data['scheme + userinfo + host (IPv6) + path + query + fragment'] = ['https://user:pass@2001:db8::ff00:42:8329/foo/bar?foo=bar#foobar', FALSE]; + $data['scheme + userinfo + host (IPv6) + query + fragment'] = ['https://user:pass@2001:db8::ff00:42:8329?foo=bar#foobar', FALSE]; + + $data['host (IPv6) + port + path'] = ['2001:db8::ff00:42:8329:1988/foo/bar', FALSE]; + $data['host (IPv6) + port + query'] = ['2001:db8::ff00:42:8329:1988?foo=bar', FALSE]; + $data['host (IPv6) + port + fragment'] = ['2001:db8::ff00:42:8329:1988#foobar', FALSE]; + $data['host (IPv6) + port + path + query'] = ['2001:db8::ff00:42:8329:1988/foo/bar?foo=bar', FALSE]; + $data['host (IPv6) + port + path + fragment'] = ['2001:db8::ff00:42:8329:1988/foo/bar#foobar', FALSE]; + $data['host (IPv6) + port + path + query + fragment'] = ['2001:db8::ff00:42:8329:1988/foo/bar?foo=bar#foobar', FALSE]; + $data['host (IPv6) + port + query + fragment'] = ['2001:db8::ff00:42:8329:1988/foo/bar?foo=bar#foobar', FALSE]; + + $data['scheme + host (IPv6) + port + path'] = ['https://2001:db8::ff00:42:8329:1988/foo/bar', FALSE]; + $data['scheme + host (IPv6) + port + query'] = ['https://2001:db8::ff00:42:8329:1988?foo=bar', FALSE]; + $data['scheme + host (IPv6) + port + fragment'] = ['https://2001:db8::ff00:42:8329:1988#foobar', FALSE]; + $data['scheme + host (IPv6) + port + path + query'] = ['https://2001:db8::ff00:42:8329:1988/foo/bar?foo=bar', FALSE]; + $data['scheme + host (IPv6) + port + path + fragment'] = ['https://2001:db8::ff00:42:8329:1988/foo/bar#foobar', FALSE]; + $data['scheme + host (IPv6) + port + path + query + fragment'] = ['https://2001:db8::ff00:42:8329:1988/foo/bar?foo=bar#foobar', FALSE]; + $data['scheme + host (IPv6) + port + query + fragment'] = ['https://2001:db8::ff00:42:8329:1988/foo/bar?foo=bar#foobar', FALSE]; + + $data['userinfo + host (IPv6) + port + path'] = ['user:pass@2001:db8::ff00:42:8329:1988/foo/bar', FALSE]; + $data['userinfo + host (IPv6) + port + query'] = ['user:pass@2001:db8::ff00:42:8329:1988?foo=bar', FALSE]; + $data['userinfo + host (IPv6) + port + fragment'] = ['user:pass@2001:db8::ff00:42:8329:1988#foobar', FALSE]; + $data['userinfo + host (IPv6) + port + path + query'] = ['user:pass@2001:db8::ff00:42:8329:1988/foo/bar?foo=bar', FALSE]; + $data['userinfo + host (IPv6) + port + path + fragment'] = ['user:pass@2001:db8::ff00:42:8329:1988/foo/bar#foobar', FALSE]; + $data['userinfo + host (IPv6) + port + path + query + fragment'] = ['user:pass@2001:db8::ff00:42:8329:1988/foo/bar?foo=bar#foobar', FALSE]; + $data['userinfo + host (IPv6) + port + query + fragment'] = ['user:pass@2001:db8::ff00:42:8329:1988/foo/bar?foo=bar#foobar', FALSE]; + + $data['scheme + userinfo + host (IPv6) + port + path'] = ['https://user:pass@2001:db8::ff00:42:8329:1988/foo/bar', FALSE]; + $data['scheme + userinfo + host (IPv6) + port + query'] = ['https://user:pass@2001:db8::ff00:42:8329:1988?foo=bar', FALSE]; + $data['scheme + userinfo + host (IPv6) + port + fragment'] = ['https://user:pass@2001:db8::ff00:42:8329:1988#foobar', FALSE]; + $data['scheme + userinfo + host (IPv6) + port + path + query'] = ['https://user:pass@2001:db8::ff00:42:8329:1988/foo/bar?foo=bar', FALSE]; + $data['scheme + userinfo + host (IPv6) + port + path + fragment'] = ['https://user:pass@2001:db8::ff00:42:8329:1988/foo/bar#foobar', FALSE]; + $data['scheme + userinfo + host (IPv6) + port + path + query + fragment'] = ['https://user:pass@2001:db8::ff00:42:8329:1988/foo/bar?foo=bar#foobar', FALSE]; + $data['scheme + userinfo + host (IPv6) + port + query + fragment'] = ['https://user:pass@2001:db8::ff00:42:8329:1988/foo/bar?foo=bar#foobar', FALSE]; + + return $data; + } + +}