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;
+ }
+
+}