\n")); // This is needed since the $user object is already destructed in _boost_ob_handler(): define('BOOST_USER_ID', @$GLOBALS['user']->uid); ////////////////////////////////////////////////////////////////////////////// // Core API hooks /** * Implementation of hook_help(). Provides online user help. */ function boost_help($path, $arg) { switch ($path) { case 'admin/help#boost': if (file_exists($file = drupal_get_path('module', 'boost') . '/README.txt')) { return '
' . implode("\n", array_slice(explode("\n", @file_get_contents($file)), 2)) . '
'; } break; case 'admin/settings/performance/boost': return '

' . t('') . '

'; // TODO: add help text. } } /** * Implementation of hook_init(). Performs page setup tasks if page not cached. */ function boost_init() { // Stop right here unless we're being called for an ordinary page request if (strpos($_SERVER['SCRIPT_FILENAME'], 'index.php') === FALSE || variable_get('site_offline', 0)) return; // TODO: check interaction with other modules that use ob_start(); this // may have to be moved to an earlier stage of the page request. if (!variable_get('cache', CACHE_DISABLED) && BOOST_ENABLED) { global $user; $GLOBALS['_boost_path'] = (empty($_REQUEST['q'])) ? 'index' : $_REQUEST['q']; // For authenticated users, set a special cookie to prevent them // inadvertently getting served pages from the static page cache. if (!empty($user->uid)) { boost_set_cookie($user); } // We only serve cached pages for GET requests by anonymous visitors. // Also pages with 'nocache' parameter in request will never be cached else if ($_SERVER['REQUEST_METHOD'] == 'GET' && !isset($_GET['nocache'])) { // Make the proper filename for our query // $_GET here is okay to use - $_GET['q'] is not involved here - see != 'q' below $fname = ''; foreach ($_GET as $key => $val) { if ($key != 'q') { $fname .= (empty($fname) ? '' : '&') . $key . '=' . $val; } } $fname = (empty($fname) ? '' : '_' . $fname); // Make sure the page is cacheable according to our current configuration: if (boost_is_cacheable($GLOBALS['_boost_path'])) { // In the event of errors such as drupal_not_found(), GET['q'] is // changed before _boost_ob_handler() is called. Apache is going to // look in the cache for the original path, however, so we need to // preserve it. $GLOBALS['_boost_query'] = $fname; ob_start('_boost_ob_handler'); } } } } /** * Implementation of hook_exit(). Performs cleanup tasks. * * For POST requests by anonymous visitors, this adds a dummy query string * to any URL being redirected to using drupal_goto(). * * This is pretty much a hack that assumes a bit too much familiarity with * what happens under the hood of the Drupal core function drupal_goto(). * * It's necessary, though, in order for any session messages set on form * submission to actually show up on the next page if that page has been * cached by Boost. */ function boost_exit($destination = NULL) { // Check that hook_exit() was invoked by drupal_goto() for a POST request: if (!empty($destination) && $_SERVER['REQUEST_METHOD'] == 'POST') { // Check that we're dealing with an anonymous visitor. and that some // session messages have actually been set during this page request: global $user; if (empty($user->uid) && ($messages = drupal_set_message())) { // FIXME: call any remaining exit hooks since we're about to terminate? $query_parts = parse_url($destination); $query_parts['path'] = ($query_parts['path'] == base_path() ? '' : substr($query_parts['path'], strlen(base_path()))); // Add a nocache parameter to query. Such pages will never be cached $query_parts['query'] .= (empty($query_parts['query']) ? '' : '&') . 'nocache=1'; $destination = url($query_parts['path'], $query_parts); // Do what drupal_goto() would do if we were to return to it: exit(header('Location: ' . $destination)); } } } /** * Implementation of hook_form_alter(). Performs alterations before a form * is rendered. */ function boost_form_alter(&$form, $form_state, $form_id) { switch ($form_id) { // Alter Drupal's system performance settings form by hiding the default // cache enabled/disabled control (which will now always default to // CACHE_DISABLED), and inject our own settings in its stead. case 'system_performance_settings': module_load_include('inc', 'boost', 'boost.admin'); $form['page_cache'] = boost_admin_settings($form['page_cache']); $form['#validate'][] = 'boost_admin_settings_validate'; $form['#submit'][] = 'boost_admin_settings_submit'; $form['clear_cache']['clear']['#submit'][0] = 'boost_admin_clear_cache_submit'; break; // Alter Drupal's site maintenance settings form in order to ensure that // the static page cache gets wiped if the administrator decides to take // the site offline. case 'system_site_maintenance_settings': module_load_include('inc', 'boost', 'boost.admin'); $form['#submit'][] = 'boost_admin_site_offline_submit'; break; // Alter Drupal's modules build form in order to ensure that // the static page cache gets wiped if the administrator decides to // change enabled modules case 'system_modules': module_load_include('inc', 'boost', 'boost.admin'); $form['#submit'][] = 'boost_admin_modules_submit'; break; // Alter Drupal's theme build form in order to ensure that // the static page cache gets wiped if the administrator decides to // change theme case 'system_themes_form': module_load_include('inc', 'boost', 'boost.admin'); $form['#submit'][] = 'boost_admin_themes_submit'; break; } } /** * Implementation of hook_cron(). Performs periodic actions. */ function boost_cron() { if (!BOOST_ENABLED) return; if (variable_get('boost_expire_cron', TRUE) && boost_cache_expire_all()) { watchdog('boost', 'Expired stale files from static page cache.', array(), WATCHDOG_NOTICE); } } /* * Implementation of hook_flush_caches(). Deletes all static files. */ function boost_flush_caches() { if (variable_get('preprocess_css', FALSE)==TRUE || variable_get('preprocess_js', FALSE)==TRUE) { boost_cache_clear_all(); } return; } /** * Implementation of hook_comment(). Acts on comment modification. */ function boost_comment($comment, $op) { if (!BOOST_ENABLED) return; switch ($op) { case 'insert': case 'update': // Expire the relevant node page from the static page cache to prevent serving stale content: if (!empty($comment['nid'])) { boost_cache_expire('node/' . $comment['nid'], TRUE); } break; case 'publish': case 'unpublish': case 'delete': if (!empty($comment->nid)) { boost_cache_expire('node/' . $comment->nid, TRUE); } break; } } /** * Implementation of hook_nodeapi(). Acts on nodes defined by other modules. */ function boost_nodeapi(&$node, $op, $teaser = NULL, $page = NULL) { if (!BOOST_ENABLED) return; switch ($op) { case 'insert': case 'update': case 'delete': // Expire all relevant node pages from the static page cache to prevent serving stale content: if (!empty($node->nid)) { boost_cache_expire('node/' . $node->nid, TRUE); } break; } } /** * Implementation of hook_taxonomy(). Acts on taxonomy changes. */ function boost_taxonomy($op, $type, $term = NULL) { if (!BOOST_ENABLED) return; switch ($op) { case 'insert': case 'update': case 'delete': // TODO: Expire all relevant taxonomy pages from the static page cache to prevent serving stale content. break; } } /** * Implementation of hook_user(). Acts on user account actions. */ function boost_user($op, &$edit, &$account, $category = NULL) { if (!BOOST_ENABLED) return; global $user; switch ($op) { case 'login': // Set a special cookie to prevent authenticated users getting served // pages from the static page cache. boost_set_cookie($user); break; case 'logout': boost_set_cookie($user, time() - 86400); break; case 'insert': // TODO: create user-specific cache directory. break; case 'delete': // Expire the relevant user page from the static page cache to prevent serving stale content: if (!empty($account->uid)) { boost_cache_expire('user/' . $account->uid); } // TODO: recursively delete user-specific cache directory. break; } } /** * Implementation of hook_block(). */ function boost_block($op = 'list', $delta = 0, $edit = array()) { global $user; switch ($op) { case 'list': return array( 'status' => array( 'info' => t('Boost page cache status'), 'region' => 'right', 'weight' => 10, 'cache' => BLOCK_NO_CACHE, ), ); case 'view': $block = array(); switch ($delta) { case 'status': // Don't show the block to anonymous users, nor on any pages that // aren't even cacheable to begin with (e.g. admin/*). if (!empty($user->uid) && boost_is_cacheable($GLOBALS['_boost_path'])) { $output = t('This page is being served live to anonymous visitors, as it is not currently in the static page cache.'); if (boost_is_cached($GLOBALS['_boost_path'])) { $ttl = boost_file_get_ttl(boost_file_path($GLOBALS['_boost_path'])); $output = t('This page is being served to anonymous visitors from the static page cache.') . ' '; $output .= t($ttl < 0 ? 'The cached copy expired %interval ago.' : 'The cached copy will expire in %interval.', array('%interval' => format_interval(abs($ttl)))); } $block['subject'] = ''; $block['content'] = theme('boost_cache_status', isset($ttl) ? $ttl : -1, $output); } break; } return $block; } } /** * Implementation of hook_theme(). */ function boost_theme() { return array( 'boost_cache_status' => array( 'arguments' => array('ttl' => NULL, 'text' => NULL), ), ); } function theme_boost_cache_status($ttl, $text) { return '' . $text . ''; } ////////////////////////////////////////////////////////////////////////////// // Output buffering callback /** * PHP output buffering callback for static page caching. * * NOTE: objects have already been destructed so $user is not available. */ function _boost_ob_handler($buffer) { // Ensure we're in the correct working directory, since some web servers (e.g. Apache) mess this up here. chdir(dirname($_SERVER['SCRIPT_FILENAME'])); // Check the currently set content type (at present we can't deal with // anything else than HTML) and the HTTP response code. We're going to be // exceedingly conservative here and only cache 'text/html' pages that // were output with a 200 OK status. Anything more is simply asking for // loads of trouble. if (_boost_get_content_type() == 'text/html' && _boost_get_http_status() == 200) { if (strlen($buffer) > 0) { // Sanity check boost_cache_set($GLOBALS['_boost_path'], $buffer); } } // Allow the page request to finish up normally return $buffer; } /** * Determines the MIME content type of the current page response based on * the currently set Content-Type HTTP header. * * This should normally return the string 'text/html' unless another module * has overridden the content type. */ function _boost_get_content_type($default = NULL) { static $regex = '!^Content-Type:\s*([\w\d\/\-]+)!i'; return _boost_get_http_header($regex, $default); } /** * Determines the HTTP response code that the current page request will be * returning by examining the HTTP headers that have been output so far. */ function _boost_get_http_status($default = 200) { static $regex = '!^HTTP/1.1\s+(\d+)!'; return (int)_boost_get_http_header($regex, $default); } function _boost_get_http_header($regex, $default = NULL) { // The last header is the one that counts: $headers = preg_grep($regex, explode("\n", drupal_set_header())); if (!empty($headers) && preg_match($regex, array_pop($headers), $matches)) { return $matches[1]; // found it } return $default; // no such luck } ////////////////////////////////////////////////////////////////////////////// // Boost API implementation /** * Determines whether a given Drupal page can be cached or not. * * To avoid potentially troublesome situations, the user login page is never * cached, nor are any admin pages. At present, we also refuse to cache any * RSS feeds provided by Drupal, since they would require special handling * in the mod_rewrite ruleset as they shouldn't be sent out using the * text/html content type. * TODO: don't cache pages with unacceptable symbols */ function boost_is_cacheable($path) { $normal_path = drupal_get_normal_path($path); // normalize path // Never cache the basic user login/registration pages or any administration pages if ($normal_path == 'user' || preg_match('!^user/(login|register|password)!', $normal_path) || preg_match('!^admin!', $normal_path)) return FALSE; // At present, RSS feeds are not cacheable due to content type restrictions if ($normal_path == 'rss.xml' || preg_match('!/feed$!', $normal_path)) return FALSE; // Don't cache comment reply pages if (preg_match('!^comment/reply!', $normal_path)) return FALSE; // Match the user's cacheability settings against the path if (BOOST_CACHEABILITY_OPTION == 2) { $result = drupal_eval(BOOST_CACHEABILITY_PAGES); return !empty($result); } $regexp = '/^('. preg_replace(array('/(\r\n?|\n)/', '/\\\\\*/', '/(^|\|)\\\\($|\|)/'), array('|', '.*', '\1'. preg_quote(variable_get('site_frontpage', 'node'), '/') .'\2'), preg_quote(BOOST_CACHEABILITY_PAGES, '/')) .')$/'; return !(BOOST_CACHEABILITY_OPTION xor preg_match($regexp, $path)); // TODO: investigate if $path or $normal_path should be used on the above line. $normal_path was introduced in a recent patch - around http://drupal.org/node/174380#comment-1477658 // TODO: document the above fat $regexp } /** * Determines whether a given Drupal page is currently cached or not. */ function boost_is_cached($path) { // no more need to check if path is empty cause it is done on the input of this function before calling it // no more need to use drupal_get_normal_path - we do not need the internal path (node/56) - we are fine with aliases return file_exists(boost_file_path($path)); } /** * Deletes all static files currently in the cache. */ function boost_cache_clear_all() { return boost_cache_expire_all(NULL); } /** * Deletes all expired static files currently in the cache. */ function boost_cache_expire_all($callback = 'boost_file_is_expired') { clearstatcache(); if (file_exists(BOOST_FILE_PATH)) { _boost_rmdir_rf(BOOST_FILE_PATH, $callback); } if (file_exists(BOOST_GZIP_FILE_PATH)) { _boost_rmdir_rf(BOOST_GZIP_FILE_PATH, $callback); } return TRUE; } /** * Expires the static file cache for a given page, or multiple pages * matching a wildcard. */ function boost_cache_expire($path, $wildcard = FALSE) { // TODO: handle wildcard. if (($filename = boost_file_path($path)) && file_exists($filename)) { @unlink($filename); $gz_filename = str_replace(BOOST_FILE_PATH, BOOST_GZIP_FILE_PATH, $filename). '.gz'; if (file_exists($gz_filename)) { @unlink($gz_filename); } } return TRUE; } /** * Returns the cached contents of the specified page, if available. */ function boost_cache_get($path) { if (($filename = boost_file_path($path))) { if (file_exists($filename) && is_readable($filename)) { return file_get_contents($filename); } } return NULL; } /** * Replaces the cached contents of the specified page, if stale. */ function boost_cache_set($path, $data = '') { // Append the Boost footer with the relevant timestamps $time = time(); $cached_at = date('Y-m-d H:i:s', $time); $expires_at = date('Y-m-d H:i:s', $time + variable_get('cache_lifetime', 600)); $data = rtrim($data) . "\n" . str_replace(array('%cached_at', '%expires_at'), array($cached_at, $expires_at), BOOST_BANNER); // Invoke hook_boost_preprocess($path, $data) foreach (module_implements('boost_preprocess') as $module) { if (($result = module_invoke($module, $path, $data)) != NULL) { $data = $result; } } // Execute the pre-process function if one has been defined if (function_exists(BOOST_PRE_PROCESS_FUNCTION)) { $data = call_user_func(BOOST_PRE_PROCESS_FUNCTION, $data); } db_set_active(); // Create or update the static file as needed if (($filename = boost_file_path($path))) { _boost_mkdir_p(dirname($filename)); if (!file_exists($filename) || boost_file_is_expired($filename)) { if (file_put_contents($filename, $data) === FALSE) { watchdog('boost', 'Unable to write file: %file', array('%file' => $filename), array(), WATCHDOG_WARNING); } } if (BOOST_GZIP) { $gz_filename = str_replace(BOOST_FILE_PATH, BOOST_GZIP_FILE_PATH, $filename). '.gz'; _boost_mkdir_p(dirname($gz_filename)); if (!file_exists($gz_filename) || boost_file_is_expired($gz_filename)) { if (file_put_contents($gz_filename, gzencode($data,9)) === FALSE) { watchdog('boost', 'Unable to write file: %file', array('%file' => $gz_filename), array(), WATCHDOG_WARNING); } } } } return TRUE; } /** * Returns the full directory path to the static file cache directory. */ function boost_cache_directory($host = NULL, $absolute = TRUE) { global $base_url; if ($base_url == "http://") { watchdog('boost', 'base_url is not set in your settings.php file. Please read #7 in boosts INSTALL.txt file.', array(), WATCHDOG_NOTICE); $base_url = $base_url . str_replace(BOOST_ROOT_CACHE_PATH . '/', '', variable_get('boost_file_path', boost_cache_directory(NULL, FALSE))); } $parts = parse_url($base_url); $host = !empty($host) ? $host : $parts['host']; // FIXME: correctly handle Drupal subdirectory installations. return implode('/', !$absolute ? array(BOOST_ROOT_CACHE_PATH, $host) : array(getcwd(), BOOST_ROOT_CACHE_PATH, $host)); } /** * Returns the static file path for a Drupal page. */ function boost_file_path($path) { // special handling for Drupal's front page no more needed - already done on the input if ($GLOBALS['_boost_query']) { $path .= $GLOBALS['_boost_query']; } // Under no circumstances should the incoming path contain '..' or null // bytes; we also limit the maximum directory nesting depth of the path if (strpos($path, '..') !== FALSE || strpos($path, "\0") !== FALSE || count(explode('/', $path)) > BOOST_MAX_PATH_DEPTH) { return FALSE; } // Don't cache path if it can't be served by apache. if (BOOST_ONLY_ASCII_PATH) { if (preg_match('@[^/a-z0-9_\-&=,\.:]@i', $path)) { return FALSE; } } return implode('/', array(BOOST_FILE_PATH, $path . BOOST_FILE_EXTENSION)); } /** * Returns the age of a cached file, measured in seconds since it was last * updated. */ function boost_file_get_age($filename) { return time() - filemtime($filename); } /** * Returns the remaining time-to-live for a cached file, measured in * seconds. */ function boost_file_get_ttl($filename) { return variable_get('cache_lifetime', 600) - boost_file_get_age($filename); } /** * Determines whether a cached file has expired, i.e. whether its age * exceeds the maximum cache lifetime as defined by Drupal's system * settings. */ function boost_file_is_expired($filename) { if (is_link($filename)) { return FALSE; // FIXME } return boost_file_get_age($filename) > variable_get('cache_lifetime', 600); } /** * Sets a special cookie preventing authenticated users getting served pages * from the static page cache. */ function boost_set_cookie($user, $expires = NULL) { if (!$expires) { $expires = ini_get('session.cookie_lifetime'); $expires = (!empty($expires) && is_numeric($expires)) ? time() + (int)$expires : 0; setcookie(BOOST_COOKIE, $user->uid, $expires, ini_get('session.cookie_path'), ini_get('session.cookie_domain'), ini_get('session.cookie_secure') == '1'); } else { setcookie(BOOST_COOKIE, FALSE, $expires, ini_get('session.cookie_path'), ini_get('session.cookie_domain'), ini_get('session.cookie_secure') == '1'); } } ////////////////////////////////////////////////////////////////////////////// // Boost API internals /** * Recursive version of mkdir(), compatible with PHP4. */ function _boost_mkdir_p($pathname, $mode = 0775, $recursive = TRUE) { if (is_dir($pathname)) return TRUE; if ($recursive && !_boost_mkdir_p(dirname($pathname), $mode)) return FALSE; if ($result = @mkdir($pathname, $mode)) @chmod($pathname, $mode); return $result; } /** * Recursive version of rmdir(); use with extreme caution. * * @param $dirname * the top-level directory that will be recursively removed * @param $callback * optional predicate function for determining if a file should be removed */ function _boost_rmdir_rf($dirname, $callback = NULL) { $empty = TRUE; // Start with an optimistic mindset $files = glob($dirname . '/*', GLOB_NOSORT); if ($files) { foreach ($files as $file) { if (is_dir($file)) { if (!_boost_rmdir_rf($file, $callback)) { $empty = FALSE; } } else if (is_file($file)) { if (function_exists($callback)) { if (!$callback($file)) { $empty = FALSE; continue; } } @unlink($file); } else { $empty = FALSE; // it's probably a symbolic link } } } // The reason for this elaborate safeguard is that Drupal will log even // warnings that should have been suppressed with the @ sign. Otherwise, // we'd just rely on the FALSE return value from rmdir(). return ($empty && @rmdir($dirname)); } ////////////////////////////////////////////////////////////////////////////// // PHP 4.x compatibility if (!function_exists('file_put_contents')) { function file_put_contents($filename, $data) { if ($fp = fopen($filename, 'wb')) { fwrite($fp, $data); fclose($fp); return filesize($filename); } return FALSE; } }