Index: .htaccess =================================================================== RCS file: /cvs/drupal/drupal/.htaccess,v retrieving revision 1.99 diff -u -9 -p -r1.99 .htaccess --- .htaccess 9 Jan 2009 02:49:01 -0000 1.99 +++ .htaccess 23 Feb 2009 22:15:27 -0000 @@ -42,20 +42,22 @@ DirectoryIndex index.php # Requires mod_expires to be enabled. # Enable expirations. ExpiresActive On # Cache all files for 2 weeks after access (A). ExpiresDefault A1209600 - # Do not cache dynamically generated pages. - ExpiresByType text/html A1 + + # Caching headers for dynamically generated pages are set from PHP. + ExpiresActive Off + # Various rewrite rules. RewriteEngine on # If your site can be accessed both with and without the 'www.' prefix, you # can use one of the following settings to redirect users to your preferred # URL, either WITH or WITHOUT the 'www.' prefix. Choose ONLY one option: Index: CHANGELOG.txt =================================================================== RCS file: /cvs/drupal/drupal/CHANGELOG.txt,v retrieving revision 1.296 diff -u -9 -p -r1.296 CHANGELOG.txt --- CHANGELOG.txt 26 Jan 2009 14:08:40 -0000 1.296 +++ CHANGELOG.txt 23 Feb 2009 22:15:28 -0000 @@ -31,18 +31,20 @@ Drupal 7.0, xxxx-xx-xx (development vers * Added an edit tab to taxonomy term pages. * Redesigned password strength validator. * Redesigned the add content type screen. * Highlight duplicate URL aliases. * Renamed "input formats" to "text formats". * Added configurable ability for users to cancel their own accounts. - Performance: * Improved performance on uncached page views by loading multiple core objects in a single database query. + * Improved support for HTTP proxies (including reverse proxies), allowing + anonymous pageviews to be served entirely from the proxy. - Documentation: * Hook API documentation now included in Drupal core. - News aggregator: * Added OPML import functionality for RSS feeds. * Optionally, RSS feeds may be configured to not automatically generate feed blocks. - Search: * Added support for language-aware searches. - Aggregator: * Introduced architecture that allows pluggable parsers and processors for Index: includes/bootstrap.inc =================================================================== RCS file: /cvs/drupal/drupal/includes/bootstrap.inc,v retrieving revision 1.269 diff -u -9 -p -r1.269 bootstrap.inc --- includes/bootstrap.inc 31 Jan 2009 16:50:56 -0000 1.269 +++ includes/bootstrap.inc 23 Feb 2009 22:15:28 -0000 @@ -742,82 +742,131 @@ function drupal_load($type, $name) { return TRUE; } return FALSE; } /** * Set HTTP headers in preparation for a page response. * - * Authenticated users are always given a 'no-cache' header, and will - * fetch a fresh page on every request. This prevents authenticated - * users seeing locally cached pages that show them as logged out. + * Authenticated users are always given a 'no-cache' header, and will fetch a + * fresh page on every request. This prevents authenticated users from seeing + * locally cached pages. + * + * Also give each page a unique ETag. This will force clients to include both + * an If-Modified-Since header and an If-None-Match header when doing + * conditional requests for the page (required by RFC 2616, section 13.3.4), + * making the validation more robust. This is a workaround for a bug in Mozilla + * Firefox that is triggered when Drupal's caching is enabled and the user + * accesses Drupal via an HTTP proxy (see + * https://bugzilla.mozilla.org/show_bug.cgi?id=269303): When an authenticated + * user requests a page, and then logs out and requests the same page again, + * Firefox may send a conditional request based on the page that was cached + * locally when the user was logged in. If this page did not have an ETag + * header, the request only contains an If-Modified-Since header. The date will + * be recent, because with authenticated users the Last-Modified header always + * refers to the time of the request. If the user accesses Drupal via a proxy + * server, and the proxy already has a cached copy of the anonymous page with an + * older Last-Modified date, the proxy may respond with 304 Not Modified, making + * the client think that the anonymous and authenticated pageviews are + * identical. * * @see page_set_cache() */ function drupal_page_header() { header("Expires: Sun, 19 Nov 1978 05:00:00 GMT"); header("Last-Modified: " . gmdate("D, d M Y H:i:s") . " GMT"); header("Cache-Control: store, no-cache, must-revalidate"); header("Cache-Control: post-check=0, pre-check=0", FALSE); + header("ETag: \"" . REQUEST_TIME . "\""); } /** * Set HTTP headers in preparation for a cached page response. * - * The general approach here is that anonymous users can keep a local - * cache of the page, but must revalidate it on every request. Then, - * they are given a '304 Not Modified' response as long as they stay - * logged out and the page has not been modified. - * + * The headers allow as much as possible in proxies and browsers without any + * particular knowledge about the pages. Modules can override these headers + * using drupal_set_header(). + * + * If the request is conditional (using If-Modified-Since and If-None-Match), + * and the conditions match those currently in the cache, a 304 Not Modified + * response is sent. */ function drupal_page_cache_header($cache) { - // Create entity tag based on cache update time. - $etag = '"' . md5($cache->created) . '"'; + // Negotiate whether to use compression. + $page_compression = variable_get('page_compression', TRUE) && extension_loaded('zlib'); + $return_compressed = $page_compression && isset($_SERVER['HTTP_ACCEPT_ENCODING']) && strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== FALSE; + + // These headers must be sent, even in the case of a 304 response (see RFC + // 2616, section 10.3.5). + // Entity tag should change if the output changes. + $etag = '"' . $cache->created . '-' . intval($return_compressed) . '"'; + header('Etag: ' . $etag); + // If a cache is served from a HTTP proxy without hitting the web server, the + // boot and exit hooks cannot be fired, so only allow caching in proxies with + // aggressive caching. + $max_age = variable_get('cache') == CACHE_AGGRESSIVE ? variable_get('cache_lifetime', 0) : 0; + header('Cache-Control: public, max-age=' . $max_age); // See if the client has provided the required HTTP headers. $if_modified_since = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ? strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) : FALSE; $if_none_match = isset($_SERVER['HTTP_IF_NONE_MATCH']) ? stripslashes($_SERVER['HTTP_IF_NONE_MATCH']) : FALSE; if ($if_modified_since && $if_none_match && $if_none_match == $etag // etag must match && $if_modified_since == $cache->created) { // if-modified-since must match header($_SERVER['SERVER_PROTOCOL'] . ' 304 Not Modified'); - // All 304 responses must send an etag if the 200 response for the same object contained an etag - header("Etag: $etag"); return; } - // Send appropriate response: - header("Last-Modified: " . gmdate(DATE_RFC1123, $cache->created)); - header("ETag: $etag"); + header('Last-Modified: ' . gmdate(DATE_RFC1123, $cache->created)); - // The following headers force validation of cache: - header("Expires: Sun, 19 Nov 1978 05:00:00 GMT"); - header("Cache-Control: must-revalidate"); + // Allow HTTP proxies to cache pages for anonymous users without a session + // cookie. The Vary header is used to indicates the set of request-header + // fields that fully determines whether a cache is permitted to use the + // response to reply to a subsequent request for a given URL without + // revalidation. + // The magic string "no-vary-cookie" may be specified using + // drupal_set_header() to indicate that a Vary: Cookie header should not be + // sent. If other headers set in this function are unwanted, they can simply + // be replaced with a different value using drupal_set_header(). + $headers = explode("\n", $cache->headers); + if (!in_array('no-vary-cookie', $headers)) { + header('Vary: Cookie'); + } - if (variable_get('page_compression', TRUE)) { - // Determine if the browser accepts gzipped data. - if (@strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') === FALSE && function_exists('gzencode')) { - // Strip the gzip header and run uncompress. - $cache->data = gzinflate(substr(substr($cache->data, 10), 0, -8)); - } - elseif (function_exists('gzencode')) { + if ($page_compression) { + header('Vary: Accept-Encoding', FALSE); + // If page_compression is enabled, the cache contains gzipped data. + if ($return_compressed) { header('Content-Encoding: gzip'); } + else { + // The client does not support compression, so unzip the data in the + // cache. Strip the gzip header and run uncompress. + $cache->data = gzinflate(substr(substr($cache->data, 10), 0, -8)); + } } + // HTTP/1.0 proxies does not support the Vary header, so prevent any caching + // by sending an Expires date in the past. HTTP/1.1 clients ignores the + // Expires header if a Cache-Control: max-age= directive is specified (see RFC + // 2616, section 14.9.3). + header('Expires: Sun, 19 Nov 1978 05:00:00 GMT'); + // Send the original request's headers. We send them one after // another so PHP's header() function can deal with duplicate // headers. - $headers = explode("\n", $cache->headers); foreach ($headers as $header) { - header($header); + // Skip magic string (see comment above). + if ($header != 'no-vary-cookie') { + header($header); + } } print $cache->data; } /** * Unserializes and appends elements from a serialized string. * * @param $obj Index: includes/common.inc =================================================================== RCS file: /cvs/drupal/drupal/includes/common.inc,v retrieving revision 1.869 diff -u -9 -p -r1.869 common.inc --- includes/common.inc 18 Feb 2009 15:07:26 -0000 1.869 +++ includes/common.inc 23 Feb 2009 22:15:28 -0000 @@ -151,29 +151,35 @@ function drupal_get_html_head() { * Reset the static variable which holds the aliases mapped for this request. */ function drupal_clear_path_cache() { drupal_lookup_path('wipe'); } /** * Set an HTTP response header for the current page. * + * The special string "no-vary-cookie" will prevent "Vary: Cookie" from being + * sent for cached responses. This is useful if a given page may be cached also + * for authenticated users. Setting the Vary header directly is not recommended. + * * Note: When sending a Content-Type header, always include a 'charset' type, * too. This is necessary to avoid security bugs (e.g. UTF-7 XSS). */ function drupal_set_header($header = NULL) { // We use an array to guarantee there are no leading or trailing delimiters. // Otherwise, header('') could get called when serving the page later, which // ends HTTP headers prematurely on some PHP versions. static $stored_headers = array(); if (strlen($header)) { - header($header); + if ($header != 'no-vary-cookie') { + header($header); + } $stored_headers[] = $header; } return implode("\n", $stored_headers); } /** * Get the HTTP response headers for the current page. */ function drupal_get_headers() { Index: modules/simpletest/tests/bootstrap.test =================================================================== RCS file: /cvs/drupal/drupal/modules/simpletest/tests/bootstrap.test,v retrieving revision 1.12 diff -u -9 -p -r1.12 bootstrap.test --- modules/simpletest/tests/bootstrap.test 31 Jan 2009 16:50:57 -0000 1.12 +++ modules/simpletest/tests/bootstrap.test 23 Feb 2009 22:15:28 -0000 @@ -84,56 +84,90 @@ class BootstrapPageCacheTestCase extends function getInfo() { return array( 'name' => t('Page cache test'), 'description' => t('Enable the page cache and test it with conditional HTTP requests.'), 'group' => t('Bootstrap') ); } + function setUp() { + parent::setUp('system_test'); + } + /** - * Enable cache and examine HTTP headers. + * Test support for requests containing If-Modified-Since and If-None-Match headers. */ - function testPageCache() { + function testConditionalRequests() { variable_set('cache', CACHE_NORMAL); // Fill the cache. $this->drupalGet(''); $this->drupalHead(''); + // When a page is served from the cache, the ETag header contains a "-". + $this->assertTrue(strpos($this->drupalGetHeader('ETag'),'-'), t('ETag header indicates that page was cached.')); $etag = $this->drupalGetHeader('ETag'); - $this->assertTrue($etag, t('An ETag header was sent, indicating that page was cached.')); $last_modified = $this->drupalGetHeader('Last-Modified'); $this->drupalGet('', array(), array('If-Modified-Since: ' . $last_modified, 'If-None-Match: ' . $etag)); $this->assertResponse(304, t('Conditional request returned 304 Not Modified.')); $this->drupalGet('', array(), array('If-Modified-Since: ' . gmdate(DATE_RFC822, strtotime($last_modified)), 'If-None-Match: ' . $etag)); $this->assertResponse(304, t('Conditional request with obsolete If-Modified-Since date returned 304 Not Modified.')); $this->drupalGet('', array(), array('If-Modified-Since: ' . gmdate(DATE_RFC850, strtotime($last_modified)), 'If-None-Match: ' . $etag)); $this->assertResponse(304, t('Conditional request with obsolete If-Modified-Since date returned 304 Not Modified.')); $this->drupalGet('', array(), array('If-Modified-Since: ' . $last_modified)); $this->assertResponse(200, t('Conditional request without If-None-Match returned 200 OK.')); - $this->assertTrue($this->drupalGetHeader('ETag'), t('An ETag header was sent, indicating that page was cached.')); + $this->assertTrue(strpos($this->drupalGetHeader('Etag'), '-'), t('Etag header indicates that page was cached.')); $this->drupalGet('', array(), array('If-Modified-Since: ' . gmdate(DATE_RFC1123, strtotime($last_modified) + 1), 'If-None-Match: ' . $etag)); $this->assertResponse(200, t('Conditional request with new a If-Modified-Since date newer than Last-Modified returned 200 OK.')); - $this->assertTrue($this->drupalGetHeader('ETag'), t('An ETag header was sent, indicating that page was cached.')); + $this->assertTrue(strpos($this->drupalGetHeader('ETag'), '-'), t('Etag header indicates that page was cached.')); $user = $this->drupalCreateUser(); $this->drupalLogin($user); $this->drupalGet('', array(), array('If-Modified-Since: ' . $last_modified, 'If-None-Match: ' . $etag)); $this->assertResponse(200, t('Conditional request returned 200 OK for authenticated user.')); - $this->assertFalse($this->drupalGetHeader('ETag'), t('An ETag header was not sent, indicating that page was not cached.')); + $this->assertFalse(strpos($this->drupalGetHeader('ETag'), '-'), t('Etag header indicates that page was not cached.')); } + /** + * Test cache headers. + */ + function testPageCache() { + variable_set('cache', CACHE_NORMAL); + + // Fill the cache. + $this->drupalGet('system-test/set-header', array('query' => array('header' => 'Foo: bar'))); + $this->assertFalse(strpos($this->drupalGetHeader('ETag'), '-'), t('Etag header indicates that page was not cached.')); + $this->assertFalse($this->drupalGetHeader('Vary'), t('Vary header was not sent.')); + $this->assertEqual($this->drupalGetHeader('Foo'), 'bar', t('Custom header was sent.')); + + // Check cache. + $this->drupalGet('system-test/set-header', array('query' => array('header' => 'Foo: bar'))); + $this->assertTrue(strpos($this->drupalGetHeader('ETag'),'-'), t('ETag header indicates that page was cached.')); + $this->assertEqual($this->drupalGetHeader('Vary'), 'Cookie,Accept-Encoding', t('Vary header was sent.')); + $this->assertEqual($this->drupalGetHeader('Foo'), 'bar', t('Custom header was sent.')); + $this->assertEqual($this->drupalGetHeader('Expires'), 'Sun, 19 Nov 1978 05:00:00 GMT', t('Expires header was sent.')); + + // Check replacing default headers. + $this->drupalGet('system-test/set-header', array('query' => array('header' => 'Expires: Fri, 19 Nov 2008 05:00:00 GMT'))); + $this->assertEqual($this->drupalGetHeader('Expires'), 'Fri, 19 Nov 2008 05:00:00 GMT', t('Default header was replaced.')); + + // Check "no-vary-header" magic string. + $this->drupalGet('system-test/set-header', array('query' => array('header' => 'no-vary-cookie'))); + $this->assertIdentical($this->drupalGetHeader('no-vary-cookie'), FALSE, t('Vary header was sent.')); + $this->drupalGet('system-test/set-header', array('query' => array('header' => 'no-vary-cookie'))); + $this->assertEqual($this->drupalGetHeader('Vary'), 'Accept-Encoding', t('Vary header was sent.')); + } } class BootstrapVariableTestCase extends DrupalWebTestCase { function setUp() { parent::setUp('system_test'); } function getInfo() { Index: modules/simpletest/tests/common.test =================================================================== RCS file: /cvs/drupal/drupal/modules/simpletest/tests/common.test,v retrieving revision 1.29 diff -u -9 -p -r1.29 common.test --- modules/simpletest/tests/common.test 18 Feb 2009 13:46:54 -0000 1.29 +++ modules/simpletest/tests/common.test 23 Feb 2009 22:15:28 -0000 @@ -588,33 +588,33 @@ class DrupalErrorHandlerUnitTest extends /** * Test the error handler. */ function testErrorHandler() { $error_notice = array( '%type' => 'Notice', '%message' => 'Undefined variable: bananas', '%function' => 'system_test_generate_warnings()', - '%line' => 184, + '%line' => 194, '%file' => realpath('modules/simpletest/tests/system_test.module'), ); $error_warning = array( '%type' => 'Warning', '%message' => 'Division by zero', '%function' => 'system_test_generate_warnings()', - '%line' => 186, + '%line' => 196, '%file' => realpath('modules/simpletest/tests/system_test.module'), ); $error_user_notice = array( '%type' => 'User notice', '%message' => 'Drupal is awesome', '%function' => 'system_test_generate_warnings()', - '%line' => 188, + '%line' => 198, '%file' => realpath('modules/simpletest/tests/system_test.module'), ); // Set error reporting to collect notices. variable_set('error_level', 2); $this->drupalGet('system-test/generate-warnings'); $this->assertErrorMessage($error_notice); $this->assertErrorMessage($error_warning); $this->assertErrorMessage($error_user_notice); @@ -636,26 +636,26 @@ class DrupalErrorHandlerUnitTest extends /** * Test the exception handler. */ function testExceptionHandler() { $error_exception = array( '%type' => 'Exception', '%message' => 'Drupal is awesome', '%function' => 'system_test_trigger_exception()', - '%line' => 197, + '%line' => 207, '%file' => realpath('modules/simpletest/tests/system_test.module'), ); $error_pdo_exception = array( '%type' => 'PDOException', '%message' => 'SQLSTATE', '%function' => 'system_test_trigger_pdo_exception()', - '%line' => 205, + '%line' => 215, '%file' => realpath('modules/simpletest/tests/system_test.module'), ); $this->drupalGet('system-test/trigger-exception'); $this->assertErrorMessage($error_exception); $this->drupalGet('system-test/trigger-pdo-exception'); // We cannot use assertErrorMessage() since the extact error reported // varies from database to database. Check for the error keyword 'SQLSTATE'. Index: modules/simpletest/tests/session.test =================================================================== RCS file: /cvs/drupal/drupal/modules/simpletest/tests/session.test,v retrieving revision 1.10 diff -u -9 -p -r1.10 session.test --- modules/simpletest/tests/session.test 19 Jan 2009 10:46:51 -0000 1.10 +++ modules/simpletest/tests/session.test 23 Feb 2009 22:15:28 -0000 @@ -157,19 +157,20 @@ class SessionTestCase extends DrupalWebT variable_set('cache', CACHE_NORMAL); // During this request the session is destroyed in drupal_page_footer(), // and the session cookie is unset. $this->drupalGet(''); $this->assertSessionCookie(TRUE); $this->assertSessionStarted(TRUE); $this->assertSessionEmpty(TRUE); - $this->assertFalse($this->drupalGetHeader('ETag'), t('Page was not cached.')); + // When a page is served from the cache, the ETag header contains a "-". + $this->assertFalse(strpos($this->drupalGetHeader('ETag'), '-'), t('Page was not cached.')); // When PHP deletes a cookie, it sends "Set-Cookie: cookiename=deleted; // expires=..." $this->assertTrue(preg_match('/SESS\w+=deleted/', $this->drupalGetHeader('Set-Cookie')), t('Session cookie was deleted.')); // Verify that the session cookie was actually deleted. $this->drupalGet(''); $this->assertSessionCookie(FALSE); $this->assertSessionStarted(FALSE); $this->assertFalse($this->drupalGetHeader('Set-Cookie'), t('New session was not started.')); @@ -179,36 +180,36 @@ class SessionTestCase extends DrupalWebT $this->assertSessionCookie(FALSE); $this->assertSessionStarted(FALSE); $this->assertTrue($this->drupalGetHeader('Set-Cookie'), t('New session was started.')); // Display the message. $this->drupalGet(''); $this->assertSessionCookie(TRUE); $this->assertSessionStarted(TRUE); $this->assertSessionEmpty(FALSE); - $this->assertFalse($this->drupalGetHeader('ETag'), t('Page was not cached.')); + $this->assertFalse(strpos($this->drupalGetHeader('ETag'), '-'), t('Page was not cached.')); $this->assertText(t('This is a dummy message.'), t('Message was displayed.')); // During this request the session is destroyed in _drupal_bootstrap(), // and the session cookie is unset. $this->drupalGet(''); $this->assertSessionCookie(TRUE); $this->assertSessionStarted(TRUE); $this->assertSessionEmpty(TRUE); - $this->assertTrue($this->drupalGetHeader('ETag'), t('Page was cached.')); + $this->assertTrue(strpos($this->drupalGetHeader('ETag'), '-'), t('Page was cached.')); $this->assertNoText(t('This is a dummy message.'), t('Message was not cached.')); $this->assertTrue(preg_match('/SESS\w+=deleted/', $this->drupalGetHeader('Set-Cookie')), t('Session cookie was deleted.')); // Verify that session was destroyed. $this->drupalGet(''); $this->assertSessionCookie(FALSE); $this->assertSessionStarted(FALSE); - $this->assertTrue($this->drupalGetHeader('ETag'), t('Page was cached.')); + $this->assertTrue(strpos($this->drupalGetHeader('ETag'), '-'), t('Page was cached.')); $this->assertFalse($this->drupalGetHeader('Set-Cookie'), t('New session was not started.')); // Verify that modifying $_SESSION without having started a session // generates a watchdog message, and that no messages have been generated // so far. $this->assertEqual($this->getWarningCount(), 0, t('No watchdog messages have been generated')); $this->drupalGet('/session-test/set-not-started'); $this->assertSessionCookie(FALSE); $this->assertSessionStarted(FALSE); Index: modules/simpletest/tests/system_test.module =================================================================== RCS file: /cvs/drupal/drupal/modules/simpletest/tests/system_test.module,v retrieving revision 1.8 diff -u -9 -p -r1.8 system_test.module --- modules/simpletest/tests/system_test.module 9 Dec 2008 11:09:26 -0000 1.8 +++ modules/simpletest/tests/system_test.module 23 Feb 2009 22:15:28 -0000 @@ -11,18 +11,23 @@ function system_test_menu() { 'type' => MENU_CALLBACK, ); $items['system-test/redirect/%'] = array( 'title' => 'Redirect', 'page callback' => 'system_test_redirect', 'page arguments' => array(2), 'access arguments' => array('access content'), 'type' => MENU_CALLBACK, ); + $items['system-test/set-header'] = array( + 'page callback' => 'system_test_set_header', + 'access arguments' => array('access content'), + 'type' => MENU_CALLBACK, + ); $items['system-test/redirect-noscheme'] = array( 'page callback' => 'system_test_redirect_noscheme', 'access arguments' => array('access content'), 'type' => MENU_CALLBACK, ); $items['system-test/redirect-noparse'] = array( 'page callback' => 'system_test_redirect_noparse', 'access arguments' => array('access content'), 'type' => MENU_CALLBACK, @@ -89,18 +94,23 @@ function system_test_basic_auth_page() { function system_test_redirect($code) { $code = (int)$code; if ($code != 200) { header("Location: " . url('system-test/redirect/200', array('absolute' => TRUE)), TRUE, $code); exit; } return ''; } +function system_test_set_header() { + drupal_set_header($_GET['header']); + return t('The following header was set: %header', array('%header' => $_GET['header'])); +} + function system_test_redirect_noscheme() { header("Location: localhost/path", TRUE, 301); exit; } function system_test_redirect_noparse() { header("Location: http:///path", TRUE, 301); exit; }