diff --git includes/common.inc includes/common.inc index 3133da3..8231826 100644 --- includes/common.inc +++ includes/common.inc @@ -3102,6 +3102,7 @@ function drupal_group_css($css) { function drupal_aggregate_css(&$css_groups) { $preprocess_css = (variable_get('preprocess_css', FALSE) && (!defined('MAINTENANCE_MODE') || MAINTENANCE_MODE != 'update')); + $map = array(); // For each group that needs aggregation, aggregate its items. foreach ($css_groups as $key => $group) { switch ($group['type']) { @@ -3109,10 +3110,17 @@ function drupal_aggregate_css(&$css_groups) { // the group's data property to the file path of the aggregate file. case 'file': if ($group['preprocess'] && $preprocess_css) { - $css_groups[$key]['data'] = drupal_build_css_cache($group['items']); + $data = drupal_build_aggregate_map($group['items'], 'css'); + if ($data) { + $css_groups[$key]['data'] = $data['uri']; + if (!empty($data['#write_cache'])) { + $map['files'][$data['key']] = $data['uri']; + $map['callbacks'][$data['uri']] = $group['items']; + } + } } break; - // Aggregate all inline CSS content into the group's data property. + // Aggregate all inline CSS content into the group's data property. case 'inline': $css_groups[$key]['data'] = ''; foreach ($group['items'] as $item) { @@ -3121,8 +3129,13 @@ function drupal_aggregate_css(&$css_groups) { break; } } + if (!empty($map)) { + variable_merge('aggregate_css_cache_files', $map); + } } + + /** * #pre_render callback to add the elements needed for CSS tags to be rendered. * @@ -3348,97 +3361,44 @@ function drupal_pre_render_styles($elements) { } /** - * Aggregates and optimizes CSS files into a cache file in the files directory. - * - * The file name for the CSS cache file is generated from the hash of the - * aggregated contents of the files in $css. This forces proxies and browsers - * to download new CSS when the CSS changes. - * - * The cache file name is retrieved on a page load via a lookup variable that - * contains an associative array. The array key is the hash of the file names - * in $css while the value is the cache file name. The cache file is generated - * in two cases. First, if there is no file name value for the key, which will - * happen if a new file name has been added to $css or after the lookup - * variable is emptied to force a rebuild of the cache. Second, the cache - * file is generated if it is missing on disk. Old cache files are not deleted - * immediately when the lookup variable is emptied, but are deleted after a set - * period by drupal_delete_file_if_stale(). This ensures that files referenced - * by a cached page will still be available. - * - * @param $css - * An array of CSS files to aggregate and compress into one file. - * - * @return - * The URI of the CSS cache file, or FALSE if the file could not be saved. + * Build the hash / filename map for aggregates. */ -function drupal_build_css_cache($css) { +function drupal_build_aggregate_map($files, $type) { $data = ''; $uri = ''; - $map = variable_get('drupal_css_cache_files', array()); - $key = hash('sha256', serialize($css)); - if (isset($map[$key])) { - $uri = $map[$key]; - } - - if (empty($uri) || !file_exists($uri)) { - // Build aggregate CSS file. - foreach ($css as $stylesheet) { - // Only 'file' stylesheets can be aggregated. - if ($stylesheet['type'] == 'file') { - $contents = drupal_load_stylesheet($stylesheet['data'], TRUE); - - // Build the base URL of this CSS file: start with the full URL. - $css_base_url = file_create_url($stylesheet['data']); - // Move to the parent. - $css_base_url = substr($css_base_url, 0, strrpos($css_base_url, '/')); - // Simplify to a relative URL if the stylesheet URL starts with the - // base URL of the website. - if (substr($css_base_url, 0, strlen($GLOBALS['base_root'])) == $GLOBALS['base_root']) { - $css_base_url = substr($css_base_url, strlen($GLOBALS['base_root'])); - } - - _drupal_build_css_path(NULL, $css_base_url . '/'); - // Anchor all paths in the CSS with its base URL, ignoring external and absolute paths. - $data .= preg_replace_callback('/url\(\s*[\'"]?(?![a-z]+:|\/+)([^\'")]+)[\'"]?\s*\)/i', '_drupal_build_css_path', $contents); - } + $map = variable_get('aggregate_' . $type . '_cache_files', array()); + $key = hash('sha256', serialize($files)); + if (isset($map['files'][$key])) { + return array('uri' => $map['files'][$key]); + } + else { + // To ensure a new filenames are created only when the contents of the + // hashed files changes, use a hash of the contents for the filename. + $function = 'drupal_collect_' . $type . '_group'; + $data = $function($files); + if ($data) { + + // Prefix filename to prevent blocking by firewalls which reject files + // starting with "ad*". + $filename = $type . '_' . drupal_hash_base64($data) . ".$type"; + // Create the aggregate directory within the files folder. + $path = "public://$type"; + $uri = $path . '/' . $filename; + + return array( + 'key' => $key, + 'uri' => $uri, + '#write_cache' => TRUE, + ); } - - // Per the W3C specification at http://www.w3.org/TR/REC-CSS2/cascade.html#at-import, - // @import rules must proceed 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; - - // Prefix filename to prevent blocking by firewalls which reject files - // starting with "ad*". - $filename = 'css_' . drupal_hash_base64($data) . '.css'; - // Create the css/ within the files folder. - $csspath = 'public://css'; - $uri = $csspath . '/' . $filename; - // Create the CSS file. - file_prepare_directory($csspath, FILE_CREATE_DIRECTORY); - if (!file_exists($uri) && !file_unmanaged_save_data($data, $uri, FILE_EXISTS_REPLACE)) { + else { return FALSE; } - // If CSS gzip compression is enabled, clean URLs are enabled (which means - // that rewrite rules are working) and the zlib extension is available then - // create a gzipped version of this file. This file is served conditionally - // to browsers that accept gzip using .htaccess rules. - if (variable_get('css_gzip_compression', TRUE) && variable_get('clean_url', 0) && extension_loaded('zlib')) { - if (!file_exists($uri . '.gz') && !file_unmanaged_save_data(gzencode($data, 9, FORCE_GZIP), $uri . '.gz', FILE_EXISTS_REPLACE)) { - return FALSE; - } - } - // Save the updated map. - $map[$key] = $uri; - variable_set('drupal_css_cache_files', $map); } - return $uri; } /** - * Helper function for drupal_build_css_cache(). + * Helper function. * * This function will prefix all paths within a CSS file. */ @@ -3595,10 +3555,161 @@ function _drupal_load_stylesheet($matches) { } /** + * Collect JavaScript files. + */ +function drupal_collect_js_group($js) { + // JavaScript aggregation currently only collects the files together, so + // re-use drupal_process_js_group(); + return drupal_process_js_group($js); +} + +/** + * College CSS files. + */ +function drupal_collect_css_group($css) { + $contents = ''; + foreach ($css as $stylesheet) { + if ($stylesheet['type'] == 'file' && file_exists($stylesheet['data'])) { + $contents .= file_get_contents($stylesheet['data']); + } + } + return $contents; +} + +/** + * Process a JavaScript group for aggregation. + */ +function drupal_process_js_group($js) { + $data = ''; + foreach ($js as $path => $info) { + if ($info['preprocess']) { + // Append a ';' and a newline after each JS file to prevent them from running together. + $data .= file_get_contents($path) . ";\n"; + } + } + return $data; +} + +/** + * Aggregate and compress css groups. + */ +function drupal_process_css_group($css) { + // Build aggregate CSS file. + $data = ''; + foreach ($css as $stylesheet) { + // Only 'file' stylesheets can be aggregated. + if ($stylesheet['type'] == 'file') { + $contents = drupal_load_stylesheet($stylesheet['data'], TRUE); + + // Build the base URL of this CSS file: start with the full URL. + $css_base_url = file_create_url($stylesheet['data']); + // Move to the parent. + $css_base_url = substr($css_base_url, 0, strrpos($css_base_url, '/')); + // Simplify to a relative URL if the stylesheet URL starts with the + // base URL of the website. + if (substr($css_base_url, 0, strlen($GLOBALS['base_root'])) == $GLOBALS['base_root']) { + $css_base_url = substr($css_base_url, strlen($GLOBALS['base_root'])); + } + + _drupal_build_css_path(NULL, $css_base_url . '/'); + // Anchor all paths in the CSS with its base URL, ignoring external and absolute paths. + $data .= preg_replace_callback('/url\(\s*[\'"]?(?![a-z]+:|\/+)([^\'")]+)[\'"]?\s*\)/i', '_drupal_build_css_path', $contents); + } + } + + // Per the W3C specification at http://www.w3.org/TR/REC-CSS2/cascade.html#at-import, + // @import rules must proceed 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; + + return $data; +} + +/** + * Menu callback to generate a css aggregate. + * + * @param $filename + * The filename for which to generate an aggregate. + * @param $type + * Either 'css' or 'js'. + */ +function drupal_generate_aggregate($filename, $type) { + // Recreate the full uri from the filename. + $path = "public://$type"; + $uri = $path . '/' . $filename; + $map = variable_get('aggregate_' . $type . '_cache_files', array()); + $compression = variable_get($type . '_gzip_compression', TRUE) && variable_get('clean_url', 0) && extension_loaded('zlib'); + + // This callback should only be called if the file does not already exist on + // disk since the webserver will serve the file directly, bypassing PHP when it does. + // However it is possible that the file was created during bootstrap by another request. + if (file_exists($uri)) { + $data = file_get_contents($uri); + if ($compression) { + $compressed = gzencode($data, 9, FORCE_GZIP); + } + } + elseif (isset($map['callbacks'][$uri])) { + $function = 'drupal_process_' . $type . '_group'; + $data = $function($map['callbacks'][$uri]); + // Check file_exists() again, in case the file was built during processing. + if (!file_exists($uri)) { + // Create the file. + file_prepare_directory($path, FILE_CREATE_DIRECTORY); + if (!file_exists($uri) && !file_unmanaged_save_data($data, $uri, FILE_EXISTS_REPLACE)) { + drupal_add_http_header('Status', '503 Service Unavailable'); + print t('Error generating aggregate'); + drupal_exit(); + } + // If gzip compression is enabled, clean URLs are enabled (which means + // that rewrite rules are working) and the zlib extension is available then + // create a gzipped version of this file. This file is served conditionally + // to browsers that accept gzip using .htaccess rules. + if ($compression) { + $compressed = gzencode($data, 9, FORCE_GZIP); + if (!file_exists($uri . '.gz') && !file_unmanaged_save_data($compressed, $uri . '.gz', FILE_EXISTS_REPLACE)) { + drupal_add_http_header('Status', '503 Service Unavailable'); + print t('Error generating aggregate'); + drupal_exit(); + } + } + } + $content_type = $type == 'css' ? 'text/css' : 'application/javascript'; + $headers = array(); + $headers['Content-Type'] = $content_type; + // Ensure this file can be cached by browsers and reverse proxies. + $headers['Cache-Control'] = 'public, max-age=1209600'; + $headers['Vary'] = 'Cookie'; + // Since the file name is based on a hash of file contents, there is no + // problem allowing the browser to cache it for as long as possible. + // Set this to two weeks since that's what .htaccess does if mod_expires + // is enabled. + $headers['Expires'] = gmdate(DATE_RFC1123, REQUEST_TIME + 1209600); + + // When possible, serve the gzip version of the file via PHP. + if (!empty($compressed) && isset($_SERVER['HTTP_ACCEPT_ENCODING']) && strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== FALSE) { + $headers['Content-Encoding'] = 'gzip'; + $data = $compressed; + ini_set('zlib.output_compression', '0'); + } + foreach ($headers as $key => $value) { + drupal_add_http_header($key, $value); + } + print $data; + drupal_exit(); + } + else { + watchdog('aggregation', 'Received request for a non-existent @type aggregate @uri.', array('@type' => $type, '@uri' => $uri)); + } +} + +/** * Deletes old cached CSS files. */ function drupal_clear_css_cache() { - variable_del('drupal_css_cache_files'); + variable_del('aggregate_css_cache_files'); file_scan_directory('public://css', '/.*/', array('callback' => 'drupal_delete_file_if_stale')); } @@ -4184,9 +4295,17 @@ function drupal_get_js($scope = 'header', $javascript = NULL, $skip_alter = FALS } // Aggregate any remaining JS files that haven't already been output. + // This is the only hunk of code change from drupal_get_js(). if ($preprocess_js && count($files) > 0) { + $map = array(); foreach ($files as $key => $file_set) { - $uri = drupal_build_js_cache($file_set); + + $data = drupal_build_aggregate_map($file_set, 'js'); + $uri = $data['uri']; + if (!empty($data['#write_cache'])) { + $map['files'][$data['key']] = $data['uri']; + $map['callbacks'][$data['uri']] = $file_set; + } // Only include the file if was written successfully. Errors are logged // using watchdog. if ($uri) { @@ -4198,6 +4317,10 @@ function drupal_get_js($scope = 'header', $javascript = NULL, $skip_alter = FALS } } + if (!empty($map)) { + variable_merge('aggregate_js_cache_files', $map); + } + // Keep the order of JS files consistent as some are preprocessed and others are not. // Make sure any inline or JS setting variables appear last after libraries have loaded. return implode('', $processed) . $output; @@ -4702,78 +4825,11 @@ function drupal_add_tabledrag($table_id, $action, $relationship, $group, $subgro } /** - * Aggregates JavaScript files into a cache file in the files directory. - * - * The file name for the JavaScript cache file is generated from the hash of - * the aggregated contents of the files in $files. This forces proxies and - * browsers to download new JavaScript when the JavaScript changes. - * - * The cache file name is retrieved on a page load via a lookup variable that - * contains an associative array. The array key is the hash of the names in - * $files while the value is the cache file name. The cache file is generated - * in two cases. First, if there is no file name value for the key, which will - * happen if a new file name has been added to $files or after the lookup - * variable is emptied to force a rebuild of the cache. Second, the cache - * file is generated if it is missing on disk. Old cache files are not deleted - * immediately when the lookup variable is emptied, but are deleted after a set - * period by drupal_delete_file_if_stale(). This ensures that files referenced - * by a cached page will still be available. - * - * @param $files - * An array of JavaScript files to aggregate and compress into one file. - * - * @return - * The URI of the cache file, or FALSE if the file could not be saved. - */ -function drupal_build_js_cache($files) { - $contents = ''; - $uri = ''; - $map = variable_get('drupal_js_cache_files', array()); - $key = hash('sha256', serialize($files)); - if (isset($map[$key])) { - $uri = $map[$key]; - } - - if (empty($uri) || !file_exists($uri)) { - // Build aggregate JS file. - foreach ($files as $path => $info) { - if ($info['preprocess']) { - // Append a ';' and a newline after each JS file to prevent them from running together. - $contents .= file_get_contents($path) . ";\n"; - } - } - // Prefix filename to prevent blocking by firewalls which reject files - // starting with "ad*". - $filename = 'js_' . drupal_hash_base64($contents) . '.js'; - // Create the js/ within the files folder. - $jspath = 'public://js'; - $uri = $jspath . '/' . $filename; - // Create the JS file. - file_prepare_directory($jspath, FILE_CREATE_DIRECTORY); - if (!file_exists($uri) && !file_unmanaged_save_data($contents, $uri, FILE_EXISTS_REPLACE)) { - return FALSE; - } - // If JS gzip compression is enabled, clean URLs are enabled (which means - // that rewrite rules are working) and the zlib extension is available then - // create a gzipped version of this file. This file is served conditionally - // to browsers that accept gzip using .htaccess rules. - if (variable_get('js_gzip_compression', TRUE) && variable_get('clean_url', 0) && extension_loaded('zlib')) { - if (!file_exists($uri . '.gz') && !file_unmanaged_save_data(gzencode($contents, 9, FORCE_GZIP), $uri . '.gz', FILE_EXISTS_REPLACE)) { - return FALSE; - } - } - $map[$key] = $uri; - variable_set('drupal_js_cache_files', $map); - } - return $uri; -} - -/** * Deletes old cached JavaScript files and variables. */ function drupal_clear_js_cache() { variable_del('javascript_parsed'); - variable_del('drupal_js_cache_files'); + variable_del('aggregate_js_cache_files'); file_scan_directory('public://js', '/.*/', array('callback' => 'drupal_delete_file_if_stale')); } @@ -7815,3 +7871,33 @@ function drupal_get_filetransfer_info() { } return $info; } + +/** + * Merge the existing value of a variable from the database with new values. + * + * @param $name + * The name of the variable. + * @param $values + * An array of values to add. + * @param $default + * The default value of the variable, as passed to variable_get(). + * This defaults to an empty array(). + */ +function variable_merge($name, $values, $default = array()) { + // This function bypasses variable_get() on purpose. Since when dealing with + // 'volatile' variables such as css and javascript maps that may be updated + // by multiple threads, it is necessary to minimize the potential race + // condition where threads may overwrite the value of the variable with a + // version missing values that were just added by a previous process. + // This approach lowers the window for that race condition to the time + // between the database query and variable_set(), which should be only a + // couple of milliseconds compared to up to over a second between + // variable_initialize() and reaching this function. + $original = $default; + $result = db_query('SELECT value FROM {variable} WHERE name = :name', array(':name' => $name))->fetchField(); + if ($result) { + $original = unserialize($result); + } + variable_set($name, array_merge_recursive($original, $values)); +} + diff --git modules/system/system.module modules/system/system.module index 2cb42b8..40301c3 100644 --- modules/system/system.module +++ modules/system/system.module @@ -511,6 +511,16 @@ function system_element_info() { * Implements hook_menu(). */ function system_menu() { + $directory_path = variable_get('file_public_path', conf_path() . '/files'); + foreach (array('css', 'js') as $type) { + $items[$directory_path . "/$type/%"] = array( + 'title' => "Generate $type aggregate", + 'page callback' => 'drupal_generate_aggregate', + 'page arguments' => array(count(explode('/', $directory_path)) + 1, $type), + 'access callback' => TRUE, + 'type' => MENU_CALLBACK, + ); + } $items['system/files'] = array( 'title' => 'File download', 'page callback' => 'file_download',