index-redis-page-cache.php 17 KB
Newer Older
Benjamin Adams's avatar
Benjamin Adams committed
1
<?php
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
/*
Plugin Name: Redis Page Cache
Plugin URI: http://eth.pw/rpc
Version: 1.0
Description: Manage settings for full-page caching powered by Redis.
Author: Erick Hitter
Author URI: https://ethitter.com/

This software is based on WP Redis Cache by Benjamin Adams, copyright 2013.

This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License, version 2, as
published by the Free Software Foundation.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
*/
25 26 27 28

/**
 * GLOBAL CONFIGURATION
 */
29
global $redis_page_cache_config;
30

31
$redis_page_cache_config = array(
32
	'debug'                   => false,
33 34 35 36 37 38 39
	'debug_messages'          => '',
	'stats'                   => false,
	'cached'                  => false,
	'server_ip'               => '127.0.0.1',
	'secret_string'           => 'changeme',
	'redis_server'            => '127.0.0.1',
	'redis_port'              => 6379,
Erick Hitter's avatar
Erick Hitter committed
40
	'redis_socket'            => null,
41 42 43 44 45
	'redis_db'                => 0,
	'cache_version'           => 0,
	'cache_headers'           => true,
	'additional_headers'      => array( 'link', 'x-hacker', 'x-pingback' ),
	'query_strings_to_ignore' => array(), // common tracking strings are automatically excluded
46
	'minify'                  => false,
47 48
);

49
// Uncomment either option below to fix the values here and disable the admin UI
50 51
// $redis_page_cache_config['cache_duration'] = 43200;
// $redis_page_cache_config['unlimited']      = false;
52

53
// Modify this function to introduce custom handling when exceptions occur
54
function redis_page_cache_exception_handler( $exception ) {
55 56 57
	return;
}

58
/**
59 60
 * END GLOBAL CONFIGURATION
 *
61 62
 * DO NOT EDIT BELOW THIS LINE!
 */
Benjamin Adams's avatar
Benjamin Adams committed
63

Ulrich Block's avatar
Ulrich Block committed
64
// Start the timer so we can track the page load time
65
if ( $redis_page_cache_config['debug'] || $redis_page_cache_config['stats'] ) {
66 67
	$start = microtime();
}
Ulrich Block's avatar
Ulrich Block committed
68

69 70 71 72
// Make run-time additions to configuration
$redis_page_cache_config['current_url'] = redis_page_cache_get_clean_url();
$redis_page_cache_config['redis_key']   = md5( 'v' . $redis_page_cache_config['cache_version'] . '-' . $redis_page_cache_config['current_url'] );
$redis_page_cache_config['redis_key']   = redis_page_cache_set_device_key( $redis_page_cache_config['redis_key'] );
73

74 75 76 77 78 79 80 81 82
/**
 * UTILITY FUNCTIONS
 */

/**
 * Compute microtime from a timestamp
 *
 * @return float
 */
83
function redis_page_cache_get_micro_time( $time ) {
Erick Hitter's avatar
Erick Hitter committed
84 85
	list( $usec, $sec ) = explode( " ", $time );
	return ( (float) $usec + (float) $sec );
Ulrich Block's avatar
Ulrich Block committed
86 87
}

88 89 90 91 92 93 94 95
/**
 * Count seconds elapsed between two microtime() timestampes
 *
 * @param string $start
 * @param string $end
 * @param int $precision
 * @return float
 */
96 97
function redis_page_cache_time_elapsed( $start, $end ) {
	return round( @redis_page_cache_get_micro_time( $end ) - @redis_page_cache_get_micro_time( $start ), 5 );
98 99
}

100 101 102 103 104
/**
 * Is the current request a refresh request with the correct secret key?
 *
 * @return bool
 */
105
function redis_page_cache_refresh_has_secret( $secret ) {
Erick Hitter's avatar
Erick Hitter committed
106
	return isset( $_GET['refresh'] ) && $secret == $_GET['refresh'];
Benjamin Adams's avatar
Benjamin Adams committed
107
}
Ulrich Block's avatar
Ulrich Block committed
108

109 110 111 112 113
/**
 * Does current request include a refresh request?
 *
 * @return bool
 */
114
function redis_page_cache_request_has_secret( $secret ) {
Erick Hitter's avatar
Erick Hitter committed
115
	return false !== strpos( $_SERVER['REQUEST_URI'], "refresh=${secret}" );
Benjamin Adams's avatar
Benjamin Adams committed
116
}
Hendrik Klemp's avatar
Hendrik Klemp committed
117

118 119 120 121 122
/**
 * Set proper IP address for proxied requests
 *
 * @return null
 */
123
function redis_page_cache_handle_cdn_remote_addressing() {
Erick Hitter's avatar
Erick Hitter committed
124
	// so we don't confuse the cloudflare server
Erick Hitter's avatar
Erick Hitter committed
125
	if ( isset( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
Erick Hitter's avatar
Erick Hitter committed
126 127
		$_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_CF_CONNECTING_IP'];
	}
Benjamin Adams's avatar
Benjamin Adams committed
128
}
129

130 131 132
/**
 * Prepare a URL for use as a cache key
 *
Erick Hitter's avatar
Erick Hitter committed
133
 * If the URL is too malformed to parse, a one-time cache is set using microtime().
134 135 136
 *
 * @return string
 */
137
function redis_page_cache_get_clean_url() {
138 139 140
	static $url;

	if ( ! $url ) {
141 142
		global $redis_page_cache_config;

143 144 145 146 147 148
		$proto = 'http';
		if ( isset( $_SERVER['HTTPS'] ) && ( 'on' === strtolower( $_SERVER['HTTPS'] ) || '1' === $_SERVER['HTTPS'] ) ) {
			$proto .= 's';
		} elseif ( isset( $_SERVER['SERVER_PORT'] ) && ( '443' == $_SERVER['SERVER_PORT'] ) ) {
			$proto .= 's';
		}
Erick Hitter's avatar
Erick Hitter committed
149

150 151
		$url = parse_url( $proto . '://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'] );
		if ( $url ) {
152 153 154
			// Query strings create their own caches, so we reduce proliferation by ignoring certain common strings
			$qs = '';
			if ( ! empty( $_GET ) ) {
155
				$ignore = array( 'c', 'flush', 'secret', 'redis-page-cache-purge', 'utm_source', 'utm_medium', 'utm_term', 'utm_content', 'utm_campaign', 'fb_action_ids', 'fb_action_types', 'fb_ref', 'fb_source', 'fb_aggregation_id', );
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175
				$ignore = array_merge( $ignore, $redis_page_cache_config['query_strings_to_ignore'] );
				$ignore = array_flip( $ignore );

				$_qs = array_diff_key( $_GET, $ignore );

				if ( ! empty( $_qs ) ) {
					$qs = '?';
					foreach ( $_qs as $key => $value ) {
						if ( strlen( $qs ) > 1 ) {
							$qs .= '&';
						}

						$qs .= "{$key}={$value}";
					}

					$qs = preg_replace( '#[^A-Z0-9=\-\?\&]#i', '', $qs );
				}
			}

			$url = $url['scheme'] . '://' . $url['host'] . $url['path'] . $qs;
176 177 178
		} else {
			$url = microtime();
		}
Erick Hitter's avatar
Erick Hitter committed
179 180 181
	}

	return $url;
Benjamin Adams's avatar
Benjamin Adams committed
182
}
Benjamin Adams's avatar
Benjamin Adams committed
183

184
/**
185 186 187 188 189
 * Prefix cache key if device calls for separate caching
 *
 * @param string $key
 * @return $string
 */
190 191
function redis_page_cache_set_device_key( $key ) {
	switch ( redis_page_cache_get_device_type() ) {
192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209
		case 'tablet' :
			$prefix = 'T-';
			break;
		case 'mobile' :
			$prefix = 'M-';
			break;
		default :
		case 'desktop' :
			$prefix = '';
			break;
	}

	return $prefix . $key;
}

/**
 * Determine the current device type from its user agent
 * Allows for separate caches for tablet, mobile, and desktop visitors
210
 *
211
 * @return string
212
 */
213
function redis_page_cache_get_device_type() {
214
	$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : '';
215 216 217 218 219 220 221 222 223 224 225 226 227

	if ( empty( $ua ) ) {
		return 'desktop';
	}

	// Tablet user agents
	if (
		false !== stripos( $ua, 'ipad'       ) ||
		( false !== stripos( $ua, 'Android'  ) && false === stripos( $ua, 'mobile' ) ) ||
		false !== stripos( $ua, 'tablet '    ) ||
		false !== stripos( $ua, 'Silk/'      ) ||
		false !== stripos( $ua, 'Kindle'     ) ||
		false !== stripos( $ua, 'PlayBook'   ) ||
228
		false !== stripos( $ua, 'RIM Tablet' )
229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244
	) {
		return 'tablet';
	}

	// Mobile user agents
	if (
		false !== stripos( $ua, 'Mobile'     ) || // many mobile devices (all iPhone, iPad, etc.)
		false !== stripos( $ua, 'Android'    ) ||
		false !== stripos( $ua, 'BlackBerry' ) ||
		false !== stripos( $ua, 'Opera Mini' ) ||
		false !== stripos( $ua, 'Opera Mobi' )
	) {
		return 'mobile';
	}

	return 'desktop';
245 246
}

247
/**
248 249 250 251 252
 * Establish a connection to the Redis server
 *
 * Will try the PECL module first, then fall back to PRedis
 *
 * @return object
253
 */
254 255
function redis_page_cache_connect_redis() {
	global $redis_page_cache_config;
256

Erick Hitter's avatar
Erick Hitter committed
257
	// check if PECL Extension is available
Erick Hitter's avatar
Erick Hitter committed
258
	if ( class_exists( 'Redis' ) ) {
259
		if ( $redis_page_cache_config['debug'] ) {
260
			$redis_page_cache_config['debug_messages'] .= "<!-- Redis: PECL -->\n";
Erick Hitter's avatar
Erick Hitter committed
261
		}
Erick Hitter's avatar
Erick Hitter committed
262

Erick Hitter's avatar
Erick Hitter committed
263
		$redis = new Redis();
Erick Hitter's avatar
Erick Hitter committed
264 265 266 267 268 269

		if ( $redis_page_cache_config['redis_socket'] ) {
			$redis->connect( $redis_page_cache_config['redis_socket'] );
		} else {
			$redis->connect( $redis_page_cache_config['redis_server'], $redis_page_cache_config['redis_port'] );
		}
270 271

		// Default DB is 0, so only need to SELECT if other
272 273
		if ( $redis_page_cache_config['redis_db'] ) {
			$redis->select( $redis_page_cache_config['redis_db'] );
274
		}
275 276
	// Fallback to predis5.2.php
	} else {
277
		if ( $redis_page_cache_config['debug'] ) {
278
			$redis_page_cache_config['debug_messages'] .= "<!-- Redis: Predis -->\n";
Erick Hitter's avatar
Erick Hitter committed
279
		}
Erick Hitter's avatar
Erick Hitter committed
280

281
		include_once dirname( __FILE__ ) . '/wp-content/plugins/redis-page-cache/predis5.2.php'; //we need this to use Redis inside of PHP
282
		$redis = array(
283 284
			'host' => $redis_page_cache_config['redis_server'],
			'port' => $redis_page_cache_config['redis_port'],
285 286 287
		);

		// Default DB is 0, so only need to SELECT if other
288 289
		if ( $redis_page_cache_config['redis_db'] ) {
			$redis['database'] = $redis_page_cache_config['redis_db'];
290 291 292
		}

		$redis = new Predis_Client( $redis );
Erick Hitter's avatar
Erick Hitter committed
293 294
	}

295 296 297 298 299 300 301 302
	return $redis;
}

/**
 * BEGIN CACHING LOGIC
 */

// Set proper IP for proxied requests
303
redis_page_cache_handle_cdn_remote_addressing();
304 305 306 307 308 309

// Ensure WP uses a theme (this is normally set in index.php)
if ( ! defined( 'WP_USE_THEMES' ) ) {
	define( 'WP_USE_THEMES', true );
}

310 311 312
// Set a header advertising the cache engine
header( 'X-Redis-Page-Cache: Redis Page Cache for WordPress by Erick Hitter (http://eth.pw/rpc)', true );

313 314
try {
	// Establish connection with Redis server
315
	$redis = redis_page_cache_connect_redis();
316

317 318 319
	// Whether we need to load WP
	$load_wp = true;

320
	// Relevant details on the current request
Erick Hitter's avatar
Erick Hitter committed
321
	$is_post_request = ( ! empty( $GLOBALS['HTTP_RAW_POST_DATA'] ) || ! empty( $_POST ) );
322
	$is_cache_exempt = (bool) preg_match( "#(wordpress_(logged|sec)|wp\-postpass|comment_author)#", var_export( $_COOKIE, true ) );
323

324
	if ( $redis_page_cache_config['debug'] ) {
Erick Hitter's avatar
Erick Hitter committed
325
		$redis_page_cache_config['debug_messages'] .= "<!-- POST request: " . ( $is_post_request ? 'yes' : 'no' ) . " -->\n";
326
		$redis_page_cache_config['debug_messages'] .= "<!-- Cache exexmpt (logged in, password-protected post, commenter): " . ( $is_cache_exempt ? 'yes' : 'no' ) . " -->\n";
327 328 329
	}

	// Refresh request, deletes cache: either manual refresh cache by adding ?refresh=secret_string after the URL or somebody posting a comment
330 331
	if ( redis_page_cache_refresh_has_secret( $redis_page_cache_config['secret_string'] ) || redis_page_cache_request_has_secret( $redis_page_cache_config['secret_string'] ) ) {
		if ( $redis_page_cache_config['debug'] ) {
332
			$redis_page_cache_config['debug_messages'] .= "<!-- Manual refresh requested -->\n";
Erick Hitter's avatar
Erick Hitter committed
333
		}
Erick Hitter's avatar
Erick Hitter committed
334

335
		$redis->del( $redis_page_cache_config['redis_key'] );
336
	// This page is cached, the user isn't exempted from cache, and it isn't a POST request, so let's use the cache
Erick Hitter's avatar
Erick Hitter committed
337
	} elseif ( ! $is_post_request && ! $is_cache_exempt && $redis->exists( $redis_page_cache_config['redis_key'] ) ) {
338
		if ( $redis_page_cache_config['debug'] ) {
339
			$redis_page_cache_config['debug_messages'] .= "<!-- Serving page from cache -->\n";
340 341
		}

342 343
		// Page is served from cache, so we don't need WP
		$load_wp = false;
344
		$redis_page_cache_config['cached'] = true;
345

346
		// Retrieve cached page, which is an array that includes meta data along with the page output
347 348
		$cache = unserialize( $redis->get( $redis_page_cache_config['redis_key'] ) );

349 350 351
		// Set headers related to content type
		header( 'Content-Type: ' . $cache['content_type'] . '; charset=' . $cache['content_encoding'], true );

352 353 354 355 356 357 358
		// Output cached headers from original page
		if ( ! empty( $cache['headers'] ) ) {
			foreach ( $cache['headers'] as $key => $value ) {
				header( "{$key}: {$value}", true );
			}
		}

359
		// Output cache headers if desired
360 361 362 363 364
		if ( $redis_page_cache_config['cache_headers'] ) {
			header( 'Last-Modified: ' . gmdate( 'D, d M Y H:i:s', $cache['time'] ) . ' GMT', true );
			header( 'Cache-Control: max-age=' . $cache['age'] . ', must-revalidate', false );
		}

365
		// Output page content
366 367
		echo trim( $cache['output'] );

368
		// Display generation stats if requested
369
		if ( 'application/xml' !== $cache['content_type'] && $redis_page_cache_config['stats'] ) {
370
			echo "\n<!-- Page cached via Redis using the Redis Page Cache plugin (http://eth.pw/rpc). -->";
371
			echo "\n<!-- Retrieved from cache in " . redis_page_cache_time_elapsed( $start, microtime() ) . " seconds. -->";
372
		}
373 374 375 376 377

		if ( $redis_page_cache_config['debug'] ) {
			$redis_page_cache_config['debug_messages'] .= "<!-- Last Modified: " . gmdate( 'D, d M Y H:i:s', $cache['time'] ) . " GMT . -->\n";
			$redis_page_cache_config['debug_messages'] .= "<!-- Max Age: " . $cache['age'] . " -->\n";
		}
Erick Hitter's avatar
Erick Hitter committed
378
	// If the cache does not exist lets display the user the normal page without cache, and then fetch a new cache page
379 380 381
	} elseif ( $_SERVER['REMOTE_ADDR'] != $redis_page_cache_config['server_ip'] ) {
		if ( false === strstr( $redis_page_cache_config['current_url'], 'preview=true' ) ) {
			if ( $redis_page_cache_config['debug'] ) {
382
				$redis_page_cache_config['debug_messages'] .= "<!-- Displaying page without cache -->\n";
383
			}
384

385
			// If user isn't exempt from caching and this isn't a post request, render the requested page and cache if appropriate.
Erick Hitter's avatar
Erick Hitter committed
386
			if ( ! $is_post_request && ! $is_cache_exempt ) {
387 388 389 390
				if ( $redis_page_cache_config['debug'] ) {
					$redis_page_cache_config['debug_messages'] .= "<!-- Adding page to cache -->\n";
				}

391 392 393 394 395
				// We load WP to generate the cached output, so no need to load again
				$load_wp = false;

				// Render page into an output buffer and display
				ob_start();
396
				require_once dirname( __FILE__ ) . '/wp-blog-header.php';
397 398
				$output = trim( ob_get_clean() );
				echo $output;
399

400
				// Display generation stats if requested
401
				if ( ! is_feed() && $redis_page_cache_config['stats'] ) {
402
					echo "\n<!-- Page NOT cached via Redis using the Redis Page Cache plugin (http://eth.pw/rpc). -->";
403
					echo "\n<!-- Generated and cached in " . redis_page_cache_time_elapsed( $start, microtime() ) . " seconds. -->";
404 405
				}

406 407
				// Cache rendered page if appropriate
				if ( ! is_404() && ! is_search() ) {
408
					// Default cache payload
409
					$cache = array(
410 411 412 413 414 415
						'output'           => $output,
						'time'             => time(),
						'age'              => 31536000, // one year in seconds
						'content_type'     => is_feed() ? 'application/xml' : 'text/html',
						'content_encoding' => get_option( 'blog_charset', 'UTF-8' ),
						'headers'          => array(),
416 417
					);

418 419 420 421 422 423 424 425
					// Minify cached content
					if ( $redis_page_cache_config['minify'] ) {
						$search = array( '#\>[^\S ]+#s', '#[^\S ]+\<#s', '#(\s)+#s' );
						$replace = array( '>', '<', '\\1' );

						$cache['output'] = preg_replace( $search, $replace, $cache['output'] );
					}

426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448
					// Capture certain headers
					// Props to @andy and Batcache (http://wordpress.org/plugins/batcache/) for this code
					if ( ! empty( $redis_page_cache_config['additional_headers' ] ) ) {
						if ( function_exists( 'headers_list' ) ) {
							foreach ( headers_list() as $header ) {
								list( $key, $value ) = array_map( 'trim', explode( ':', $header, 2 ) );
								$cache['headers'][ $key ] = $value;
							}
						} elseif ( function_exists( 'apache_response_headers' ) ) {
							$cache['headers'] = apache_response_headers();
						}

						if ( $cache['headers'] ) {
							foreach ( $cache['headers'] as $key => $value ) {
								if ( ! in_array( strtolower( $key ), $redis_page_cache_config['additional_headers' ] ) )
									unset( $cache['headers'][$key] );
							}
						}

						unset( $key );
						unset( $value );
					}

449
					// Is unlimited cache life requested?
450 451
					if ( ! isset( $redis_page_cache_config['unlimited'] ) ) {
						$redis_page_cache_config['unlimited'] = (bool) get_option( 'redis-page-cache-debug', false );
452 453
					}

454
					// Cache the page for the chosen duration
455
					if ( $redis_page_cache_config['unlimited'] ) {
456
						$redis->set( $redis_page_cache_config['redis_key'], serialize( $cache ) );
457
					} else {
458 459
						if ( ! isset( $redis_page_cache_config['cache_duration'] ) ) {
							$redis_page_cache_config['cache_duration'] = (int) get_option( 'redis-page-cache-seconds', 43200 );
460 461
						}

462 463
						if ( ! is_numeric( $redis_page_cache_config['cache_duration'] ) ) {
							$redis_page_cache_config['cache_duration'] = 43200;
464 465
						}

466 467 468
						$cache['age'] = $redis_page_cache_config['cache_duration'];

						$redis->setex( $redis_page_cache_config['redis_key'], $redis_page_cache_config['cache_duration'], serialize( $cache ) );
469
					}
Erick Hitter's avatar
Erick Hitter committed
470
				}
471 472 473
			}
		}
	}
474 475 476

	// The current request wasn't served from cache or isn't cacheable, so we pass off to WP
	if ( $load_wp ) {
477
		require_once dirname( __FILE__ ) . '/wp-blog-header.php';
Erick Hitter's avatar
Erick Hitter committed
478
	}
Erick Hitter's avatar
Erick Hitter committed
479
} catch ( Exception $e ) {
480
	require_once dirname( __FILE__ ) . '/wp-blog-header.php';
481
	redis_page_cache_exception_handler( $e );
Benjamin Adams's avatar
Benjamin Adams committed
482 483
}

484 485 486
/**
 * DEBUGGING OUTPUT
 */
487
if ( $redis_page_cache_config['debug'] ) {
488
	$redis_page_cache_config['debug_messages'] .= "<!-- Redis Page Cache by Erick Hitter (http://eth.pw/rpc). Page generated in " . redis_page_cache_time_elapsed( $start, microtime() ) . " seconds. -->\n";
489 490
	$redis_page_cache_config['debug_messages'] .= "<!-- Cache key: " . $redis_page_cache_config['redis_key'] . " -->\n";
	$redis_page_cache_config['debug_messages'] .= "<!-- Cached URL: " . redis_page_cache_get_clean_url() . " -->\n";
491 492 493 494 495 496 497

	if ( isset( $redis_page_cache_config['unlimited'] ) && $redis_page_cache_config['unlimited'] ) {
		$cache_duration = 'infinite';
	} elseif ( isset( $redis_page_cache_config['cache_duration'] ) ) {
		$cache_duration = $redis_page_cache_config['cache_duration'];
	} else {
		$cache_duration = 'unknown';
Erick Hitter's avatar
Erick Hitter committed
498
	}
499 500 501
	$redis_page_cache_config['debug_messages'] .= "<!-- Cache duration in seconds: " . $cache_duration . " -->\n";

	$redis_page_cache_config['debug_messages'] .= "<!-- Server IP: " . $redis_page_cache_config['server_ip'] . " -->\n";
502

503
	echo "\n" . $redis_page_cache_config['debug_messages'];
Benjamin Adams's avatar
Benjamin Adams committed
504
}