diff --git a/.htaccess b/.htaccess index f4024c6..47d0f5b 100644 --- a/.htaccess +++ b/.htaccess @@ -2,6 +2,9 @@ # Apache/PHP/Drupal settings: # +php_value auto_prepend_file /Users/catch/xhprof/header.php +php_value auto_append_file /Users/catch/xhprof/footer.php + # Protect files and directories from prying eyes. diff --git a/core/core.services.yml b/core/core.services.yml index 7c1e473..fa2b645 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1450,7 +1450,7 @@ services: arguments: [ '@state' ] asset.css.collection_optimizer: class: Drupal\Core\Asset\CssCollectionOptimizer - arguments: [ '@asset.css.collection_grouper', '@asset.css.optimizer', '@asset.css.dumper', '@state' ] + arguments: [ '@asset.css.collection_grouper', '@asset.css.optimizer', '@theme.manager' ] asset.css.optimizer: class: Drupal\Core\Asset\CssOptimizer asset.css.collection_grouper: diff --git a/core/lib/Drupal/Core/Asset/AssetDumper.php b/core/lib/Drupal/Core/Asset/AssetDumper.php index 0504fa4..9e0470b 100644 --- a/core/lib/Drupal/Core/Asset/AssetDumper.php +++ b/core/lib/Drupal/Core/Asset/AssetDumper.php @@ -21,13 +21,16 @@ class AssetDumper implements AssetDumperInterface { * the aggregated contents of the files in $data. This forces proxies and * browsers to download new CSS when the CSS changes. */ - public function dump($data, $file_extension) { - // Prefix filename to prevent blocking by firewalls which reject files - // starting with "ad*". - $filename = $file_extension. '_' . Crypt::hashBase64($data) . '.' . $file_extension; - // Create the css/ or js/ path within the files folder. + public function dump($data, $file_extension, $uri = NULL) { $path = 'public://' . $file_extension; - $uri = $path . '/' . $filename; + if (!isset($uri)) { + // Prefix filename to prevent blocking by firewalls which reject files + // starting with "ad*". + $filename = $file_extension . '_' . Crypt::hashBase64($data) . '.' . $file_extension; + // Create the css/ or js/ path within the files folder. + $path = 'public://' . $file_extension; + $uri = $path . '/' . $filename; + } // 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/AssetDumperInterface.php b/core/lib/Drupal/Core/Asset/AssetDumperInterface.php index 5f70aa6..8b997eb 100644 --- a/core/lib/Drupal/Core/Asset/AssetDumperInterface.php +++ b/core/lib/Drupal/Core/Asset/AssetDumperInterface.php @@ -19,9 +19,12 @@ * @param string $file_extension * The file extension of this asset. * + * @param string $uri + * (optional) The file URI to write to. + * * @return string * An URI to access the dumped asset. */ - public function dump($data, $file_extension); + public function dump($data, $file_extension, $uri); } diff --git a/core/lib/Drupal/Core/Asset/AssetResolver.php b/core/lib/Drupal/Core/Asset/AssetResolver.php index 742cb2c..c05e5f6 100644 --- a/core/lib/Drupal/Core/Asset/AssetResolver.php +++ b/core/lib/Drupal/Core/Asset/AssetResolver.php @@ -122,7 +122,24 @@ public function getCssAssets(AttachedAssetsInterface $assets, $optimize) { if ($cached = $this->cache->get($cid)) { return $cached->data; } + else { + $css = $this->getCssFromLibraries($libraries_to_load, $optimize); + } + if ($optimize) { + $css = \Drupal::service('asset.css.collection_optimizer')->optimize($css); + } + $this->cache->set($cid, $css, CacheBackendInterface::CACHE_PERMANENT, ['library_info']); + + return $css; + + } + /** + * Get css assets based on a list of libraries. + * @todo: interface. + */ + public function getCssFromLibraries($libraries, $optimize) { + $theme_info = $this->themeManager->getActiveTheme(); $css = []; $default_options = [ 'type' => 'file', @@ -133,7 +150,7 @@ public function getCssAssets(AttachedAssetsInterface $assets, $optimize) { 'browsers' => [], ]; - foreach ($libraries_to_load as $library) { + foreach ($libraries as $library) { list($extension, $name) = explode('/', $library, 2); $definition = $this->libraryDiscovery->getLibraryByName($extension, $name); if (isset($definition['css'])) { @@ -152,6 +169,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; @@ -176,11 +194,6 @@ public function getCssAssets(AttachedAssetsInterface $assets, $optimize) { } } - if ($optimize) { - $css = \Drupal::service('asset.css.collection_optimizer')->optimize($css); - } - $this->cache->set($cid, $css, CacheBackendInterface::CACHE_PERMANENT, ['library_info']); - return $css; } diff --git a/core/lib/Drupal/Core/Asset/CssCollectionOptimizer.php b/core/lib/Drupal/Core/Asset/CssCollectionOptimizer.php index 6cfca51..1c20033 100644 --- a/core/lib/Drupal/Core/Asset/CssCollectionOptimizer.php +++ b/core/lib/Drupal/Core/Asset/CssCollectionOptimizer.php @@ -8,6 +8,8 @@ namespace Drupal\Core\Asset; use Drupal\Core\State\StateInterface; +use \Drupal\Component\Utility\UrlHelper; +use \Drupal\Core\Theme\ThemeManagerInterface; /** * Optimizes CSS assets. @@ -29,18 +31,11 @@ class CssCollectionOptimizer implements AssetCollectionOptimizerInterface { protected $optimizer; /** - * An asset dumper. + * The theme manager. * - * @var \Drupal\Core\Asset\AssetDumper + * @var \Drupal\Core\Theme\ThemeManagerInterface */ - protected $dumper; - - /** - * The state key/value store. - * - * @var \Drupal\Core\State\StateInterface - */ - protected $state; + protected $themeManager; /** * Constructs a CssCollectionOptimizer. @@ -49,16 +44,11 @@ class CssCollectionOptimizer implements AssetCollectionOptimizerInterface { * The grouper for CSS assets. * @param \Drupal\Core\Asset\AssetOptimizerInterface * The optimizer for a single CSS asset. - * @param \Drupal\Core\Asset\AssetDumperInterface - * The dumper for optimized CSS assets. - * @param \Drupal\Core\State\StateInterface - * The state key/value store. */ - public function __construct(AssetCollectionGrouperInterface $grouper, AssetOptimizerInterface $optimizer, AssetDumperInterface $dumper, StateInterface $state) { + public function __construct(AssetCollectionGrouperInterface $grouper, AssetOptimizerInterface $optimizer, ThemeManagerInterface $theme_manager) { $this->grouper = $grouper; $this->optimizer = $optimizer; - $this->dumper = $dumper; - $this->state = $state; + $this->themeManager = $theme_manager; } /** @@ -78,15 +68,10 @@ public function __construct(AssetCollectionGrouperInterface $grouper, AssetOptim public function optimize(array $css_assets) { // Group the assets. $css_groups = $this->grouper->group($css_assets); + $key = $this->generateHash($css_groups); - // Now optimize (concatenate + minify) and dump each asset group, unless - // that was already done, in which case it should appear in - // drupal_css_cache_files. - // Drupal contrib can override this default CSS aggregator to keep the same - // grouping, optimizing and dumping, but change the strategy that is used to - // determine when the aggregate should be rebuilt (e.g. mtime, HTTPS …). - $map = $this->state->get('drupal_css_cache_files') ?: array(); $css_assets = array(); + $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 @@ -101,38 +86,13 @@ public function optimize(array $css_assets) { $uri = $css_group['items'][0]['data']; $css_assets[$order]['data'] = $uri; } - // Preprocess (aggregate), unless the aggregate file already exists. else { - $key = $this->generateHash($css_group); - $uri = ''; - if (isset($map[$key])) { - $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; - // 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. - $css_assets[$order]['data'] = $uri; - // Persist the URI for this aggregate file. - $map[$key] = $uri; - $this->state->set('drupal_css_cache_files', $map); - } - else { - // Use the persisted URI for the optimized CSS file. - $css_assets[$order]['data'] = $uri; + // 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; } @@ -156,6 +116,21 @@ public function optimize(array $css_assets) { break; } } + // Generate a URL for the group, but do not process it inline, this is + // done by \Drupal\system\controller\CssAssetController + $minimal_set = \Drupal::service('library.dependency_resolver')->getMinimalRepresentativeSubset($libraries); + + foreach ($css_assets as $order => $css_asset) { + if (!empty($css_asset['preprocessed'])) { + $filename = 'css' . '_' . $order . '_' . $key . '.' . 'css'; + // Create the css/ or js/ path within the files folder. + $path = 'public://' . 'css'; + $uri = $path . '/' . $filename; + $theme_name = $this->themeManager->getActiveTheme()->getName(); + $query = UrlHelper::buildQuery(['libraries' => $minimal_set, 'theme' => $theme_name]); + $css_assets[$order]['data'] = file_create_url($uri) . '?' . $query; + } + } return $css_assets; } @@ -170,11 +145,13 @@ public function optimize(array $css_assets) { * A hash to uniquely identify the given group of CSS assets. */ protected function generateHash(array $css_group) { - $css_data = array(); - foreach ($css_group['items'] as $css_file) { - $css_data[] = $css_file['data']; + $normalized = $css_group; + foreach ($normalized as $order => $group) { + foreach ($group['items'] as $key => $asset) { + unset($normalized[$order]['items'][$key]['weight']); + } } - return hash('sha256', serialize($css_data)); + return hash('sha256', serialize($normalized)); } /** @@ -188,8 +165,6 @@ public function getAll() { * {@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')) { diff --git a/core/modules/system/src/Controller/CssAssetController.php b/core/modules/system/src/Controller/CssAssetController.php new file mode 100644 index 0000000..dc1261d --- /dev/null +++ b/core/modules/system/src/Controller/CssAssetController.php @@ -0,0 +1,282 @@ +libraryDependencyResolver = $library_dependency_resolver; + $this->assetResolver = $asset_resolver; + $this->themeInitialization = $theme_initialization; + $this->themeManager = $theme_manager; + $this->grouper = $grouper; + $this->optimizer = $optimizer; + $this->dumper = $dumper; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->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.optimizer'), + $container->get('asset.css.dumper') + ); + } + + /** + * Generates a derivative, given a style and image path. + * + * After generating an image, transfer it to the requesting agent. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * @param $file_name + * The file to deliver. + * + * @return \Symfony\Component\HttpFoundation\BinaryFileResponse|\Symfony\Component\HttpFoundation\Response + * The transferred file as response or some error response. + * + * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException + * Thrown when the user does not have access to the file. + * @throws \Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException + * Thrown when the file is still being generated. + */ + public function deliver(Request $request, $file_name) { + $theme = $request->query->get('theme'); + $active_theme = $this->themeInitialization->initTheme($theme); + $this->themeManager->setActiveTheme($active_theme); + + // While the libraries are taken from the query parameter, the URL as a + // whole is validated against a hash of the CSS assets later on. + $libraries = $this->libraryDependencyResolver->getLibrariesWithDependencies($request->query->get('libraries')); + $css_assets = $this->assetResolver->getCssFromLibraries($libraries, TRUE); + + // Group the assets. + $css_groups = $this->grouper->group($css_assets); + + // Now optimize (concatenate + minify) and dump each asset group, unless + // that was already done, in which case it should appear in + // drupal_css_cache_files. + // Drupal contrib can override this default CSS aggregator to keep the same + // grouping, optimizing and dumping, but change the strategy that is used to + // determine when the aggregate should be rebuilt (e.g. mtime, HTTPS …). + $css_assets = array(); + $libraries = []; + + $base_name = basename($file_name, '.css'); + + $file_parts = explode('_', $base_name); + // The group delta is the second segment of the filename, if it's not there + // then the filename is invalid. + if (!isset($file_parts[1]) || !is_numeric($file_parts[1])) { + throw new BadRequestHttpException('Invalid filename'); + } + $group_delta = $file_parts[1]; + + // The hash is the third segment of the filename. + if (!isset($file_parts[2])) { + throw new BadRequestHttpException('Invalid filename'); + } + $hash = $file_parts[2]; + + // If the group being requested does not exist, assume an invalid filename. + if (!isset($css_groups[$group_delta])) { + throw new BadRequestHttpException('Invalid filename'); + } + $css_group = $css_groups[$group_delta]; + + // Only groups that are preprocessed will be requested, so don't try to + // process ones that aren't. + if (!$css_group['preprocess']) { + throw new BadRequestHttpException('Invalid filename'); + } + $key = $this->generateHash($css_groups); + + // 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 requrested 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. + // + // @todo: we could potentially hash the library definitions with the secret + // key to create an HMAC, so that only valid combinations of libraries are + // allowed. + // For now treat all of these the same, but if there's no match, don't write + // to the filesystem. + $match = TRUE; + + $uri = 'public://css/' . $file_name; + if ($key !== $hash) { + // The file requested may have been written to disk by the time we got + // here. If it hasn't, and the hashes don't match, it's possible that a + // file matching the code base for this request already exists on disk, so + // use the filename matching this code base, not the one that generated + // the original filename for the request. + if (!file_exists($uri)) { + $uri = 'public://css/' . 'css_' . $group_delta . '_' . $hash . '.css'; + } + // Either way, we didn't get a match. + $match = FALSE; + } + $headers = [ + 'Content-Type' => 'text/css', + // 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. + 'Cache-control' => 'private, no-store', + ]; + + if (!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; + // Dump the optimized CSS for this group into an aggregate file. + if ($match) { + $uri = $this->dumper->dump($data, 'css', $uri); + } + $headers = [ + 'Content-Type' => 'text/css', + 'Cache-control' => 'private, no-store', + ]; + // Generate response. + $response = new Response($data, 200, $headers); + return $response; + } + else { + return new BinaryFileResponse($uri, 200, $headers); + } + } + + /** + * Generate a hash for a given group of CSS assets. + * + * @param array $css_group + * A group of CSS assets. + * + * @return string + * A hash to uniquely identify the given group of CSS assets. + */ + protected function generateHash(array $css_group) { + $normalized = $css_group; + foreach ($normalized as $order => $group) { + foreach ($group['items'] as $key => $asset) { + unset($normalized[$order]['items'][$key]['weight']); + } + } + return hash('sha256', serialize($normalized)); + } + +} diff --git a/core/modules/system/src/Routing/AssetRoutes.php b/core/modules/system/src/Routing/AssetRoutes.php new file mode 100644 index 0000000..841fc67 --- /dev/null +++ b/core/modules/system/src/Routing/AssetRoutes.php @@ -0,0 +1,71 @@ +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', + ) + ); + return $routes; + } + +} diff --git a/core/modules/system/system.routing.yml b/core/modules/system/system.routing.yml index ed111f8..aa2dfe7 100644 --- a/core/modules/system/system.routing.yml +++ b/core/modules/system/system.routing.yml @@ -485,3 +485,6 @@ system.entity_autocomplete: _controller: '\Drupal\system\Controller\EntityAutocompleteController::handleAutocomplete' requirements: _access: 'TRUE' + +route_callbacks: + - '\Drupal\system\Routing\AssetRoutes::routes'