cdn.install | 7 ++ cdn.routing.yml | 11 ++ cdn.services.yml | 2 +- cdn_ui/js/summaries.js | 9 ++ cdn_ui/src/Form/CdnSettingsForm.php | 113 +++++++++++++++++++++ config/install/cdn.settings.yml | 3 + config/schema/cdn.data_types.schema.yml | 5 + config/schema/cdn.schema.yml | 5 + src/CdnFarfutureController.php | 95 +++++++++++++++-- src/CdnSettings.php | 57 +++++++++++ src/File/FileUrlGenerator.php | 75 +++++++------- src/PathProcessor/CdnFarfuturePathProcessor.php | 50 +++++++-- .../Constraint/CdnStreamWrapperConstraint.php | 21 ++++ .../CdnStreamWrapperConstraintValidator.php | 50 +++++++++ tests/src/Functional/CdnIntegrationTest.php | 27 ++++- .../CdnStreamWrapperConstraintValidatorTest.php | 64 ++++++++++++ tests/src/Unit/File/FileUrlGeneratorTest.php | 52 +++++++--- 17 files changed, 577 insertions(+), 69 deletions(-) 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 f7b19ed..8e86ad9 100644 --- a/cdn.services.yml +++ b/cdn.services.yml @@ -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 9dfac05..a2be3ff 100644 --- a/cdn_ui/src/Form/CdnSettingsForm.php +++ b/cdn_ui/src/Form/CdnSettingsForm.php @@ -2,14 +2,46 @@ namespace Drupal\cdn_ui\Form; +use Drupal\Component\Utility\UrlHelper; use Drupal\Core\Config\Config; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Config\TypedConfigManagerInterface; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\StreamWrapper\StreamWrapperInterface; +use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; /** * Configure CDN settings for this site. */ class CdnSettingsForm extends ValidatableConfigFormBase { + /** + * The stream wrapper manager. + * + * @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface + */ + protected $streamWrapperManager; + + /** + * @inheritDoc + */ + public function __construct(ConfigFactoryInterface $config_factory, TypedConfigManagerInterface $typed_config_manager, StreamWrapperManagerInterface $streamWrapperManager) { + parent::__construct($config_factory, $typed_config_manager); + $this->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} */ @@ -152,9 +184,84 @@ class CdnSettingsForm extends ValidatableConfigFormBase { '#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' => !empty($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; + if (!empty($form['wrappers']['stream_wrappers']['private'])) { + $form['wrappers']['stream_wrappers']['private']['#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); } + /** + * 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 + */ + 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; + } + } + + /** + * Builds the stream wrapper checkboxes form array. + * + * @param string[] $stream_wrapper_schemes + * The stream wrapper schemes for which to generate form checkboxes. + * + * @return array + */ + 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} */ @@ -162,6 +269,12 @@ class CdnSettingsForm extends ValidatableConfigFormBase { // 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']); 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 b109173..9f2006d 100644 --- a/config/schema/cdn.data_types.schema.yml +++ b/config/schema/cdn.data_types.schema.yml @@ -7,3 +7,8 @@ cdn.domain: label: 'Domain' constraints: CdnDomain: [] +cdn.stream_wrapper_scheme: + type: string + label: 'Stream wrapper scheme' + constraints: + CdnStreamWrapper: [] diff --git a/config/schema/cdn.schema.yml b/config/schema/cdn.schema.yml index b0d9272..a741365 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: cdn.stream_wrapper_scheme diff --git a/src/CdnFarfutureController.php b/src/CdnFarfutureController.php index e1f7bea..aad83a6 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,76 @@ 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. + * @param \Drupal\Core\File\FileSystemInterface $file_system + * The file system service. */ - public function __construct(PrivateKey $private_key) { + public function __construct(PrivateKey $private_key, FileSystemInterface $file_system) { $this->privateKey = $private_key; + $this->fileSystem = $file_system; + } + + /** + * 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 + // Path comes with a leading slash from the URL. + : $scheme . ':/' . $path; + // 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 +115,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 +131,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 +180,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 6219eb2..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). * @@ -149,4 +163,47 @@ class CdnSettings { return $lookup_table; } + /** + * Maps a URI to a CDN domain. + * + * @param string $uri + * The URI to map. + * + * @return string|bool + * The mapped domain, or FALSE if it could not be matched. + */ + 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]; + + 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/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..10a114e 100644 --- a/src/PathProcessor/CdnFarfuturePathProcessor.php +++ b/src/PathProcessor/CdnFarfuturePathProcessor.php @@ -12,6 +12,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 +23,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/CdnStreamWrapperConstraint.php b/src/Plugin/Validation/Constraint/CdnStreamWrapperConstraint.php new file mode 100644 index 0000000..2c26a0a --- /dev/null +++ b/src/Plugin/Validation/Constraint/CdnStreamWrapperConstraint.php @@ -0,0 +1,21 @@ +context->buildViolation($constraint->message) + ->setParameter('%stream_wrapper', $stream_wrapper) + ->setInvalidValue($stream_wrapper) + ->addViolation(); + } + } + + /** + * Validates the given stream wrapper, with an exception for "private". + * + * @param string $stream_wrapper + * A stream wrapper configured for use in Drupal. + * + * @return bool + */ + protected static function isValidCdnStreamWrapper($stream_wrapper) { + /** @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $stream_wrapper_manager */ + $stream_wrapper_manager = \Drupal::service('stream_wrapper_manager'); + $forbidden_wrappers = ['private']; + return !in_array($stream_wrapper, $forbidden_wrappers, TRUE) + && $stream_wrapper_manager->getClass($stream_wrapper) !== FALSE; + } + +} diff --git a/tests/src/Functional/CdnIntegrationTest.php b/tests/src/Functional/CdnIntegrationTest.php index 009230e..da276bd 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 testOldFarfuture() { $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/Kernel/CdnStreamWrapperConstraintValidatorTest.php b/tests/src/Kernel/CdnStreamWrapperConstraintValidatorTest.php new file mode 100644 index 0000000..17e3ebb --- /dev/null +++ b/tests/src/Kernel/CdnStreamWrapperConstraintValidatorTest.php @@ -0,0 +1,64 @@ +prophesize(ConstraintViolationBuilderInterface::class); + $constraint_violation_builder->setParameter('%stream_wrapper', $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 CdnStreamWrapperConstraint(); + + $validate = new CdnStreamWrapperConstraintValidator(); + $validate->initialize($context->reveal()); + $validate->validate($value, $constraint); + } + + public function provideTestValidate() { + $data = []; + + $data['public, registered by default'] = ['public', TRUE]; + $data['private, prohibited by rule'] = ['private', FALSE]; + $data['unregistered'] = ['unregistered', FALSE]; + $data['custom, provided by test module'] = ['dummy-remote', TRUE]; + + return $data; + } + +} 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()) ); }