diff --git a/core/core.services.yml b/core/core.services.yml index 0b18c3b..666c192 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1499,8 +1499,8 @@ services: class: Drupal\Core\Asset\CssCollectionRenderer arguments: [ '@state' ] asset.css.collection_optimizer: - class: Drupal\Core\Asset\CssCollectionOptimizer - arguments: [ '@asset.css.collection_grouper', '@asset.css.optimizer', '@asset.css.dumper', '@state' ] + class: Drupal\Core\Asset\CssCollectionOptimizerLazy + arguments: [ '@asset.css.collection_grouper', '@asset.css.optimizer', '@theme.manager', '@library.dependency_resolver', '@request_stack', '@state' ] asset.css.optimizer: class: Drupal\Core\Asset\CssOptimizer asset.css.collection_grouper: @@ -1511,8 +1511,8 @@ services: class: Drupal\Core\Asset\JsCollectionRenderer arguments: [ '@state' ] asset.js.collection_optimizer: - class: Drupal\Core\Asset\JsCollectionOptimizer - arguments: [ '@asset.js.collection_grouper', '@asset.js.optimizer', '@asset.js.dumper', '@state' ] + class: Drupal\Core\Asset\JsCollectionOptimizerLazy + arguments: [ '@asset.js.collection_grouper', '@asset.js.optimizer', '@theme.manager', '@library.dependency_resolver', '@request_stack', '@state' ] asset.js.optimizer: class: Drupal\Core\Asset\JsOptimizer asset.js.collection_grouper: diff --git a/core/lib/Drupal/Core/Asset/AssetCollectionGroupOptimizerInterface.php b/core/lib/Drupal/Core/Asset/AssetCollectionGroupOptimizerInterface.php new file mode 100644 index 0000000..142fba6 --- /dev/null +++ b/core/lib/Drupal/Core/Asset/AssetCollectionGroupOptimizerInterface.php @@ -0,0 +1,21 @@ +dumpToUri($data, $file_extension, $uri); + } + + /** + * {@inheritdoc} + */ + public function dumpToUri($data, $file_extension, $uri) { + $path = 'public://' . $file_extension; // Create the CSS or JS file. file_prepare_directory($path, FILE_CREATE_DIRECTORY); if (!file_exists($uri) && !file_unmanaged_save_data($data, $uri, FILE_EXISTS_REPLACE)) { diff --git a/core/lib/Drupal/Core/Asset/AssetDumperUriInterface.php b/core/lib/Drupal/Core/Asset/AssetDumperUriInterface.php new file mode 100644 index 0000000..b81d4a4 --- /dev/null +++ b/core/lib/Drupal/Core/Asset/AssetDumperUriInterface.php @@ -0,0 +1,25 @@ + $group) { + foreach ($group['items'] as $key => $asset) { + unset($normalized[$order]['items'][$key]['weight']); + } + } + return hash('sha256', serialize($normalized)); + } + +} diff --git a/core/lib/Drupal/Core/Asset/AssetResolver.php b/core/lib/Drupal/Core/Asset/AssetResolver.php index 9e1bcce..76c30bc 100644 --- a/core/lib/Drupal/Core/Asset/AssetResolver.php +++ b/core/lib/Drupal/Core/Asset/AssetResolver.php @@ -148,6 +148,7 @@ public function getCssAssets(AttachedAssetsInterface $assets, $optimize) { // Always add a tiny value to the weight, to conserve the insertion // order. $options['weight'] += count($css) / 1000; + $options['_library'] = $library; // CSS files are being keyed by the full path. $css[$options['data']] = $options; @@ -265,6 +266,10 @@ public function getJsAssets(AttachedAssetsInterface $assets, $optimize) { // order. $options['weight'] += count($javascript) / 1000; + // Add the library so that it can be identified from the individual + // asset. + $options['_library'] = $library; + // Local and external files must keep their name as the associative // key so the same JavaScript file is not added twice. $javascript[$options['data']] = $options; diff --git a/core/lib/Drupal/Core/Asset/CssCollectionOptimizer.php b/core/lib/Drupal/Core/Asset/CssCollectionOptimizer.php index eb47cb2..7a7f601 100644 --- a/core/lib/Drupal/Core/Asset/CssCollectionOptimizer.php +++ b/core/lib/Drupal/Core/Asset/CssCollectionOptimizer.php @@ -6,8 +6,11 @@ /** * Optimizes CSS assets. + * + * @deprecated as of Drupal 8.3.x, will be removed before Drupal 9.0.0 */ class CssCollectionOptimizer implements AssetCollectionOptimizerInterface { + use CssCollectionOptimizerTrait; /** * A CSS asset grouper. @@ -31,13 +34,6 @@ class CssCollectionOptimizer implements AssetCollectionOptimizerInterface { protected $dumper; /** - * The state key/value store. - * - * @var \Drupal\Core\State\StateInterface - */ - protected $state; - - /** * Constructs a CssCollectionOptimizer. * * @param \Drupal\Core\Asset\AssetCollectionGrouperInterface $grouper @@ -104,19 +100,7 @@ public function optimize(array $css_assets) { $uri = $map[$key]; } if (empty($uri) || !file_exists($uri)) { - // Optimize each asset within the group. - $data = ''; - foreach ($css_group['items'] as $css_asset) { - $data .= $this->optimizer->optimize($css_asset); - } - // Per the W3C specification at - // http://www.w3.org/TR/REC-CSS2/cascade.html#at-import, @import - // rules must precede any other style, so we move those to the - // top. - $regexp = '/@import[^;]+;/i'; - preg_match_all($regexp, $data, $matches); - $data = preg_replace($regexp, '', $data); - $data = implode('', $matches[0]) . $data; + $data = $this->optimizeGroup($css_group); // Dump the optimized CSS for this group into an aggregate file. $uri = $this->dumper->dump($data, 'css'); // Set the URI for this group's aggregate file. @@ -162,26 +146,5 @@ protected function generateHash(array $css_group) { return hash('sha256', serialize($css_data)); } - /** - * {@inheritdoc} - */ - public function getAll() { - return $this->state->get('drupal_css_cache_files'); - } - - /** - * {@inheritdoc} - */ - public function deleteAll() { - $this->state->delete('drupal_css_cache_files'); - - $delete_stale = function($uri) { - // Default stale file threshold is 30 days. - if (REQUEST_TIME - filemtime($uri) > \Drupal::config('system.performance')->get('stale_file_threshold')) { - file_unmanaged_delete($uri); - } - }; - file_scan_directory('public://css', '/.*/', array('callback' => $delete_stale)); - } } diff --git a/core/lib/Drupal/Core/Asset/CssCollectionOptimizerLazy.php b/core/lib/Drupal/Core/Asset/CssCollectionOptimizerLazy.php new file mode 100644 index 0000000..46c3dac --- /dev/null +++ b/core/lib/Drupal/Core/Asset/CssCollectionOptimizerLazy.php @@ -0,0 +1,142 @@ +grouper = $grouper; + $this->optimizer = $optimizer; + $this->themeManager = $theme_manager; + $this->dependencyResolver = $dependency_resolver; + $this->requestStack = $request_stack; + $this->state = $state; + } + + /** + * {@inheritdoc} + * + * File names are generated based on library/asset definitions. This includes + * a hash of the assets and the group index. Additionally the full set of + * libraries, already loaded libraries and theme are sent as query parameters + * to allow a PHP controller to generate a valid file with sufficient + * information. Files are not generated by this method since they're assumed + * to be successfully returned from the URL created whether on disk or not. + */ + public function optimize(array $css_assets) { + // Group the assets. + $css_groups = $this->grouper->group($css_assets); + $key = $this->generateHash($css_groups); + + $css_assets = []; + $libraries = []; + foreach ($css_groups as $order => $css_group) { + // We have to return a single asset, not a group of assets. It is now up + // to one of the pieces of code in the switch statement below to set the + // 'data' property to the appropriate value. + $css_assets[$order] = $css_group; + unset($css_assets[$order]['items']); + + switch ($css_group['type']) { + case 'file': + // No preprocessing, single CSS asset: just use the existing URI. + if (!$css_group['preprocess']) { + $uri = $css_group['items'][0]['data']; + $css_assets[$order]['data'] = $uri; + } + else { + // To reproduce the full context of assets outside of the request, + // we must know the entire set of libraries used to generate all CSS + // groups, whether or not files in a group are from a particular + // library or not. + foreach ($css_group['items'] as $css_asset) { + $libraries[$css_asset['_library']] = $css_asset['_library']; + } + $css_assets[$order]['preprocessed'] = TRUE; + } + break; + + case 'external': + // We don't do any aggregation and hence also no caching for external + // CSS assets. + $uri = $css_group['items'][0]['data']; + $css_assets[$order]['data'] = $uri; + break; + } + } + // Generate a URL for each group of assets, but do not process them inline, + // this is done using optimizeGroup() when the asset path is requested. + $ajax_page_state = $this->requestStack->getCurrentRequest()->get('ajax_page_state'); + $already_loaded = isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : []; + $query_args = [ + 'theme' => $this->themeManager->getActiveTheme()->getName(), + 'libraries' => $this->dependencyResolver->getMinimalRepresentativeSubset($libraries), + 'already_loaded' => $this->dependencyResolver->getMinimalRepresentativeSubset($already_loaded), + ]; + $query = UrlHelper::buildQuery($query_args); + foreach ($css_assets as $order => $css_asset) { + if (!empty($css_asset['preprocessed'])) { + $filename = 'css' . '_' . $order . '_' . $key . '.css'; + $uri = 'public://css/' . $filename; + $css_assets[$order]['data'] = file_create_url($uri) . '?' . $query; + } + } + + return $css_assets; + } + +} diff --git a/core/lib/Drupal/Core/Asset/CssCollectionOptimizerTrait.php b/core/lib/Drupal/Core/Asset/CssCollectionOptimizerTrait.php new file mode 100644 index 0000000..f4057dc --- /dev/null +++ b/core/lib/Drupal/Core/Asset/CssCollectionOptimizerTrait.php @@ -0,0 +1,73 @@ +optimizer->optimize($asset); + } + // Per the W3C specification at + // http://www.w3.org/TR/REC-CSS2/cascade.html#at-import, @import + // rules must precede any other style, so we move those to the + // top. + $regexp = '/@import[^;]+;/i'; + preg_match_all($regexp, $data, $matches); + $data = preg_replace($regexp, '', $data); + return implode('', $matches[0]) . $data; + } + + /** + * Deletes all optimized asset collections assets. + */ + public function deleteAll() { + $this->state->delete('drupal_css_cache_files'); + $delete_stale = function($uri) { + // Default stale file threshold is 30 days. + if (REQUEST_TIME - filemtime($uri) > \Drupal::config('system.performance')->get('stale_file_threshold')) { + file_unmanaged_delete($uri); + } + }; + file_scan_directory('public://css', '/.*/', array('callback' => $delete_stale)); + } + + /** + * Returns all optimized asset collections assets. + * + * @return string[] + * URIs for all optimized asset collection assets. + */ + public function getAll() { + return $this->state->get('drupal_css_cache_files', []); + } + +} diff --git a/core/lib/Drupal/Core/Asset/JsCollectionOptimizer.php b/core/lib/Drupal/Core/Asset/JsCollectionOptimizer.php index 566722d..4210492 100644 --- a/core/lib/Drupal/Core/Asset/JsCollectionOptimizer.php +++ b/core/lib/Drupal/Core/Asset/JsCollectionOptimizer.php @@ -4,11 +4,14 @@ use Drupal\Core\State\StateInterface; - /** * Optimizes JavaScript assets. + * + * @deprecated as of Drupal 8.3.x, will be removed before Drupal 9.0.0 + * */ class JsCollectionOptimizer implements AssetCollectionOptimizerInterface { + use JsCollectionOptimizerTrait; /** * A JS asset grouper. @@ -32,13 +35,6 @@ class JsCollectionOptimizer implements AssetCollectionOptimizerInterface { protected $dumper; /** - * The state key/value store. - * - * @var \Drupal\Core\State\StateInterface - */ - protected $state; - - /** * Constructs a JsCollectionOptimizer. * * @param \Drupal\Core\Asset\AssetCollectionGrouperInterface $grouper @@ -105,22 +101,7 @@ public function optimize(array $js_assets) { $uri = $map[$key]; } if (empty($uri) || !file_exists($uri)) { - // Concatenate each asset within the group. - $data = ''; - foreach ($js_group['items'] as $js_asset) { - // Optimize this JS file, but only if it's not yet minified. - if (isset($js_asset['minified']) && $js_asset['minified']) { - $data .= file_get_contents($js_asset['data']); - } - else { - $data .= $this->optimizer->optimize($js_asset); - } - // Append a ';' and a newline after each JS file to prevent them - // from running together. - $data .= ";\n"; - } - // Remove unwanted JS code that cause issues. - $data = $this->optimizer->clean($data); + $data = $this->optimizeGroup($js_group); // Dump the optimized JS for this group into an aggregate file. $uri = $this->dumper->dump($data, 'js'); // Set the URI for this group's aggregate file. @@ -166,25 +147,4 @@ protected function generateHash(array $js_group) { return hash('sha256', serialize($js_data)); } - /** - * {@inheritdoc} - */ - public function getAll() { - return $this->state->get('system.js_cache_files'); - } - - /** - * {@inheritdoc} - */ - public function deleteAll() { - $this->state->delete('system.js_cache_files'); - $delete_stale = function($uri) { - // Default stale file threshold is 30 days. - if (REQUEST_TIME - filemtime($uri) > \Drupal::config('system.performance')->get('stale_file_threshold')) { - file_unmanaged_delete($uri); - } - }; - file_scan_directory('public://js', '/.*/', array('callback' => $delete_stale)); - } - } diff --git a/core/lib/Drupal/Core/Asset/JsCollectionOptimizerLazy.php b/core/lib/Drupal/Core/Asset/JsCollectionOptimizerLazy.php new file mode 100644 index 0000000..58cc573 --- /dev/null +++ b/core/lib/Drupal/Core/Asset/JsCollectionOptimizerLazy.php @@ -0,0 +1,152 @@ +grouper = $grouper; + $this->optimizer = $optimizer; + $this->themeManager = $theme_manager; + $this->dependencyResolver = $dependency_resolver; + $this->requestStack = $request_stack; + $this->state = $state; + } + + /** + * {@inheritdoc} + * + * File names are generated based on library/asset definitions. This includes + * a hash of the assets and the group index. Additionally the full set of + * libraries, already loaded libraries and theme are sent as query parameters + * to allow a PHP controller to generate a valid file with sufficient + * information. Files are not generated by this method since they're assumed + * to be successfully returned from the URL created whether on disk or not. + */ + public function optimize(array $js_assets) { + // Group the assets. + $js_groups = $this->grouper->group($js_assets); + $key = $this->generateHash($js_groups); + + $js_assets = array(); + $libraries = []; + foreach ($js_groups as $order => $js_group) { + // We have to return a single asset, not a group of assets. It is now up + // to one of the pieces of code in the switch statement below to set the + // 'data' property to the appropriate value. + $js_assets[$order] = $js_group; + unset($js_assets[$order]['items']); + + switch ($js_group['type']) { + case 'file': + // No preprocessing, single JS asset: just use the existing URI. + if (!$js_group['preprocess']) { + $uri = $js_group['items'][0]['data']; + $js_assets[$order]['data'] = $uri; + } + else { + // To reproduce the full context of assets outside of the request, + // we must know the entire set of libraries used to generate all CSS + // groups, whether or not files in a group are from a particular + // library or not. + foreach ($js_group['items'] as $js_asset) { + $libraries[$js_asset['_library']] = $js_asset['_library']; + } + $js_assets[$order]['preprocessed'] = TRUE; + } + break; + + case 'external': + // We don't do any aggregation and hence also no caching for external + // JS assets. + $uri = $js_group['items'][0]['data']; + $js_assets[$order]['data'] = $uri; + break; + + case 'setting': + $js_assets[$order]['data'] = $js_group['data']; + break; + } + } + if ($libraries) { + // Generate a URL for the group, but do not process it inline, this is + // done by \Drupal\system\controller\JsAssetController. + $ajax_page_state = $this->requestStack->getCurrentRequest()->query->get('ajax_page_state'); + $already_loaded = isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : []; + + $query_args = [ + 'libraries' => $this->dependencyResolver->getMinimalRepresentativeSubset($libraries), + 'theme' => $this->themeManager->getActiveTheme()->getName(), + 'already_loaded' => $this->dependencyResolver->getMinimalRepresentativeSubset($already_loaded), + ]; + $header_query = UrlHelper::buildQuery($query_args + ['scope' => 'header']); + $footer_query = UrlHelper::buildQuery($query_args + ['scope' => 'footer']); + foreach ($js_assets as $order => $js_asset) { + if (!empty($js_asset['preprocessed'])) { + $query = $js_asset['scope'] == 'header' ? $header_query : $footer_query; + $filename = 'js_' . $order . '_' . $key . '.js'; + $uri = 'public://js/' . $filename; + $js_assets[$order]['data'] = file_create_url($uri) . '?' . $query; + } + } + } + + return $js_assets; + } + +} diff --git a/core/lib/Drupal/Core/Asset/JsCollectionOptimizerTrait.php b/core/lib/Drupal/Core/Asset/JsCollectionOptimizerTrait.php new file mode 100644 index 0000000..4cbe72a --- /dev/null +++ b/core/lib/Drupal/Core/Asset/JsCollectionOptimizerTrait.php @@ -0,0 +1,75 @@ +optimizer->optimize($js_asset); + } + // Append a ';' and a newline after each JS file to prevent them from + // running together. + $data .= ";\n"; + } + // Remove unwanted JS code that cause issues. + return $this->optimizer->clean($data); + } + + /** + * Deletes all optimized asset collections assets. + */ + public function deleteAll() { + $this->state->delete('system.js_cache_files'); + $delete_stale = function($uri) { + // Default stale file threshold is 30 days. + if (REQUEST_TIME - filemtime($uri) > \Drupal::config('system.performance')->get('stale_file_threshold')) { + file_unmanaged_delete($uri); + } + }; + file_scan_directory('public://js', '/.*/', array('callback' => $delete_stale)); + } + + /** + * Returns all optimized asset collections assets. + * + * @return string[] + * URIs for all optimized asset collection assets. + */ + public function getAll() { + return $this->state->get('system.js_cache_files', []); + } + +} diff --git a/core/modules/system/src/Controller/AssetControllerBase.php b/core/modules/system/src/Controller/AssetControllerBase.php new file mode 100644 index 0000000..a763dcf --- /dev/null +++ b/core/modules/system/src/Controller/AssetControllerBase.php @@ -0,0 +1,263 @@ +libraryDependencyResolver = $library_dependency_resolver; + $this->assetResolver = $asset_resolver; + $this->themeInitialization = $theme_initialization; + $this->themeManager = $theme_manager; + $this->grouper = $grouper; + $this->optimizer = $optimizer; + $this->state = $state; + $this->dumper = $dumper; + $this->fileExtension = $this->assetType; + } + + /** + * Generates an aggregate, given a filename. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * @param string $file_name + * The file to deliver. + * + * @return \Symfony\Component\HttpFoundation\BinaryFileResponse|\Symfony\Component\HttpFoundation\Response + * The transferred file as response. + * + * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * Thrown when the filename is invalid. + */ + public function deliver(Request $request, $file_name) { + $uri = 'public://' . $this->assetType . '/' . $file_name; + + // Check to see whether a file matching the $uri already exists, this can + // happen if it was created while this request was in progress. + if (file_exists($uri)) { + return new BinaryFileResponse($uri, 200, ['Cache-control' => $this->cacheControl]); + } + + // First validate that the request is valid enough to produce an asset group + // aggregate. The theme must be passed as a query parameter, since assets + // always depend on the current theme. + if (!$request->query->has('theme')) { + throw new BadRequestHttpException('The theme must be passed as a query argument'); + } + $file_parts = explode('_', basename($file_name, '.' . $this->fileExtension)); + + // The group delta is the second segment of the filename and the hash is the + // third segment. If either are not there, then the filename is invalid. + if (!isset($file_parts[1]) || !is_numeric($file_parts[1]) || !isset($file_parts[2])) { + throw new BadRequestHttpException('Invalid filename'); + } + $group_delta = $file_parts[1]; + $received_hash = $file_parts[2]; + + // Now build the asset groups based on the libraries. It requires the full + // set of asset groups to extract and build the aggregate for the group we + // want, since libraries may be split across different asset groups. + $theme = $request->query->get('theme'); + $active_theme = $this->themeInitialization->initTheme($theme); + $this->themeManager->setActiveTheme($active_theme); + + $attached_assets = new AttachedAssets(); + $attached_assets->setLibraries($request->query->get('libraries')); + if ($request->query->has('already_loaded')) { + $attached_assets->setAlreadyLoadedLibraries($request->query->get('already_loaded')); + } + $groups = $this->getGroups($attached_assets, $request); + + // Generate a hash based on the asset groups, this uses the same method as + // the collection optimizer does to create the filename, so it should match. + $generated_hash = $this->generateHash($groups); + $group = $this->getGroup($groups, $group_delta); + $data = $this->optimizer->optimizeGroup($group); + + // However, the hash from the library definitions in code may not match the + // hash from the URL. This can be for three reasons: + // 1. Someone has requested an outdated URL, i.e. from a cached page, which + // matches a different version of the code base. + // 2. Someone has requested an outdated URL during a deployment. This is + // the same case as #1 but a much shorter window. + // 3. Someone is attempting to craft an invalid URL in order to conduct a + // denial of service attack on the site. + // + // Dump the optimized group into an aggregate file, but only if the + // received hash and generated hash match. This prevents invalid filenames + // from filling the disk, while still serving aggregates that may be + // referenced in cached HTML. + if ($received_hash == $generated_hash) { + $uri = $this->dumper->dumpToUri($data, $this->assetType, $uri); + $state_key = 'drupal_' . $this->assetType . '_cache_files'; + $files = $this->state->get($state_key, []); + $files[] = $uri; + $this->state->set($state_key, $files); + } + // Headers sent from PHP can never perfectly match those sent when the + // file is served by the filesystem, so ensure this request does not get + // cached in either the browser or reverse proxies. Subsequent requests + // for the file will be served from disk and be cached. This is done to + // avoid situations such as where one CDN endpoint is serving a version + // cached from PHP, while another is serving a version cached from disk. + // Should there be any discrepancy in behaviour between those files, this + // can make debugging very difficult. + $response = new Response($data, 200, ['Cache-control' => $this->cacheControl, 'Content-Type' => $this->contentType]); + return $response; + } + + /** + * Get a group. + * + * @param array $groups + * An array of asset groups. + * @param int $group_delta + * The group delta. + * + * @return array + * The correct asset group matching $group_delta. + */ + protected function getGroup($groups, $group_delta) { + if (isset($groups[$group_delta])) { + return $groups[$group_delta]; + } + throw new BadRequestHttpException('Invalid filename.'); + } + + /** + * Get grouped assets. + * + * @param Drupal\Core\Asset\AttachedAssetsInterface $attached_assets + * The attached assets. + * @param Symfony\Component\HttpFoundation\Request $request + * The current request. + */ + abstract protected function getGroups(AttachedAssetsInterface $attached_assets, Request $request); + +} diff --git a/core/modules/system/src/Controller/CssAssetController.php b/core/modules/system/src/Controller/CssAssetController.php new file mode 100644 index 0000000..10404b3 --- /dev/null +++ b/core/modules/system/src/Controller/CssAssetController.php @@ -0,0 +1,55 @@ +get('library.dependency_resolver'), + $container->get('asset.resolver'), + $container->get('theme.initialization'), + $container->get('theme.manager'), + $container->get('asset.css.collection_grouper'), + $container->get('asset.css.collection_optimizer'), + $container->get('asset.css.dumper'), + $container->get('state') + ); + } + + /** + * {@inheritdoc} + */ + protected function getGroups(AttachedAssetsInterface $attached_assets, Request $request) { + $assets = $this->assetResolver->getCssAssets($attached_assets, FALSE); + return $this->grouper->group($assets); + } + +} diff --git a/core/modules/system/src/Controller/JsAssetController.php b/core/modules/system/src/Controller/JsAssetController.php new file mode 100644 index 0000000..4e7e091 --- /dev/null +++ b/core/modules/system/src/Controller/JsAssetController.php @@ -0,0 +1,67 @@ +get('library.dependency_resolver'), + $container->get('asset.resolver'), + $container->get('theme.initialization'), + $container->get('theme.manager'), + $container->get('asset.js.collection_grouper'), + $container->get('asset.js.collection_optimizer'), + $container->get('asset.js.dumper'), + $container->get('state') + ); + } + + /** + * {@inheritdoc} + */ + protected function getGroups(AttachedAssetsInterface $attached_assets, Request $request) { + // The header and footer scripts are two distinct sets of asset groups. The + // $group_key is not sufficient to find the group, we also need to locate it + // within either the header or footer set. + list($js_assets_header, $js_assets_footer) = $this->assetResolver->getJsAssets($attached_assets, FALSE); + $scope = $request->get('scope'); + if (!isset($scope)) { + throw new BadRequestHttpException('The URL must have a scope query argument.'); + } + $assets = $scope == 'header' ? $js_assets_header : $js_assets_footer; + // While the asset resolver will find setting, these are never aggregated, + // so filter them out. Settings are always in the header. + unset($assets['drupalSettings']); + return $this->grouper->group($assets); + } + +} diff --git a/core/modules/system/src/Routing/AssetRoutes.php b/core/modules/system/src/Routing/AssetRoutes.php new file mode 100644 index 0000000..c7d2de0 --- /dev/null +++ b/core/modules/system/src/Routing/AssetRoutes.php @@ -0,0 +1,75 @@ +streamWrapperManager = $stream_wrapper_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('stream_wrapper_manager') + ); + } + + /** + * Returns an array of route objects. + * + * @return \Symfony\Component\Routing\Route[] + * An array of route objects. + */ + public function routes() { + $routes = array(); + // Generate assets. If clean URLs are disabled image derivatives will always + // be served through the routing system. If clean URLs are enabled and the + // image derivative already exists, PHP will be bypassed. + $directory_path = $this->streamWrapperManager->getViaScheme('public')->getDirectoryPath(); + + $routes['system.css_asset'] = new Route( + '/' . $directory_path . '/css/{file_name}', + array( + '_controller' => 'Drupal\system\Controller\CssAssetController::deliver', + ), + array( + '_access' => 'TRUE', + ) + ); + $routes['system.js_asset'] = new Route( + '/' . $directory_path . '/js/{file_name}', + array( + '_controller' => 'Drupal\system\Controller\JsAssetController::deliver', + ), + array( + '_access' => 'TRUE', + ) + ); + return $routes; + } + +} diff --git a/core/modules/system/system.routing.yml b/core/modules/system/system.routing.yml index ae89fb0..54accc5 100644 --- a/core/modules/system/system.routing.yml +++ b/core/modules/system/system.routing.yml @@ -507,3 +507,6 @@ system.csrftoken: _controller: '\Drupal\system\Controller\CsrfTokenController::csrfToken' requirements: _access: 'TRUE' + +route_callbacks: + - '\Drupal\system\Routing\AssetRoutes::routes' diff --git a/core/tests/Drupal/FunctionalTests/Asset/AssetOptimizationTest.php b/core/tests/Drupal/FunctionalTests/Asset/AssetOptimizationTest.php new file mode 100644 index 0000000..fee8b00 --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Asset/AssetOptimizationTest.php @@ -0,0 +1,188 @@ +config('system.performance')->set('css', ['preprocess' => TRUE, 'gzip' => TRUE])->save(); + $this->config('system.performance')->set('js', ['preprocess' => TRUE, 'gzip' => TRUE])->save(); + $user = $this->createUser(); + $this->drupalLogin($user); + $this->drupalGet(''); + $session = $this->getSession(); + $page = $session->getPage(); + + $elements = $page->findAll('xpath', '//link[@rel="stylesheet"]'); + $urls = []; + foreach ($elements as $element) { + if ($element->hasAttribute('href')) { + $urls[] = $element->getAttribute('href'); + } + } + foreach ($urls as $url) { + $this->assertAggregate($url, TRUE); + } + foreach ($urls as $url) { + $this->assertAggregate($url, FALSE); + } + + foreach ($urls as $url) { + $this->assertInvalidAggregates($url); + } + + $elements = $page->findAll('xpath', '//script'); + $urls = []; + foreach ($elements as $element) { + if ($element->hasAttribute('src')) { + $urls[] = $element->getAttribute('src'); + } + } + foreach ($urls as $url) { + $this->assertAggregate($url, TRUE); + } + foreach ($urls as $url) { + $this->assertAggregate($url, FALSE); + } + foreach ($urls as $url) { + $this->assertInvalidAggregates($url, 'js'); + } + } + + /** + * Tests the URL points to a valid page, and has expected headers. + * + * @param string $url + * The url under test. + * @param bool $from_php + * (optional) is the source of the url php code. Default to TRUE. + */ + protected function assertAggregate($url, $from_php = TRUE) { + $url = $this->getAbsoluteUrl($url); + $session = $this->getSession(); + $session->visit($url); + $this->assertResponse(200); + $headers = $session->getResponseHeaders(); + if ($from_php) { + $this->assertEqual($headers['Cache-Control'], ['no-store, private']); + } + else { + $this->assertTrue(!isset($headers['Cache-Control'])); + } + } + + /** + * Test the returned response code under various failure conditions. + * + * @param string $url + * The url under tests. + */ + protected function assertInvalidAggregates($url) { + $session = $this->getSession(); + $session->visit($this->replaceGroupDelta($url)); + $this->assertResponse(400); + + $session->visit($this->setInvalidLibrary($url)); + $this->assertResponse(400); + + $session->visit($this->omitTheme($url)); + $this->assertResponse(400); + + $session->visit($this->replaceGroupHash($url)); + $this->assertResponse(200); + $headers = $session->getResponseHeaders(); + $this->assertEqual($headers['Cache-Control'], ['no-store, private']); + + // And again to confirm it's not cached on disk. + $session->visit($this->replaceGroupHash($url)); + $this->assertResponse(200); + $headers = $session->getResponseHeaders(); + $this->assertEqual($headers['Cache-Control'], ['no-store, private']); + + } + + /** + * Invalidate the group delta section of a URL. + * + * @param string $url + * The URL to be modified. + * + * @return string + * The modified absolute URL. + */ + protected function replaceGroupDelta($url) { + $parts = explode('_', $url); + $parts[1] = 100; + return $this->getAbsoluteUrl(implode('_', $parts)); + } + + /** + * Invalidate the hash section of the URL, for example to ensure a cache miss. + * + * @param string $url + * The URL to be modified. + * + * @return string + * The modified absolute URL. + */ + protected function replaceGroupHash($url) { + $parts = explode('_', $url); + $hash = strtok($parts[2], '.'); + $parts[2] = str_replace($hash, 'abcdefghijklmnop', $parts[2]); + return $this->getAbsoluteUrl(implode('_', $parts)); + } + + /** + * Invalidate the library definition associated with a URL. + * + * @param string $url + * The associated URL. + * + * @return string + * The modified absolute URL. + */ + protected function setInvalidLibrary($url) { + // First replace the hash so we don't get served the actual file on disk. + $url = $this->replaceGroupHash($url); + $parts = UrlHelper::parse($url); + $parts['query']['libraries'] = ['system/llama']; + + $query = UrlHelper::buildQuery($parts['query']); + return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']); + } + + /** + * Delete the theme section from a given URL. + * + * @param string $url + * The URL to modify. + * + * @return string + * The modified absolute URL. + */ + protected function omitTheme($url) { + // First replace the hash so we don't get served the actual file on disk. + $url = $this->replaceGroupHash($url); + $parts = UrlHelper::parse($url); + unset($parts['query']['theme']); + $query = UrlHelper::buildQuery($parts['query']); + return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']); + } + +}