Automatic WordPress security: local firewall, malware and file-integrity scanning, vulnerability protection, quarantine, scheduled backups, an optional page cache, and automatic global threat intelligence. See README.md for installation, update, and uninstall instructions.
496 lines
16 KiB
PHP
496 lines
16 KiB
PHP
<?php
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
class Argus_Static_Cache {
|
|
|
|
const STATUS_HIT = 'HIT';
|
|
const STATUS_MISS = 'MISS';
|
|
const STATUS_BYPASS = 'BYPASS';
|
|
const STATUS_EXPIRED = 'EXPIRED';
|
|
const STATUS_STALE = 'STALE';
|
|
const STATUS_REVALIDATED = 'REVALIDATED';
|
|
const STATUS_WARMED = 'WARMED';
|
|
const STATUS_PURGED = 'PURGED';
|
|
const STATUS_EXCLUDED = 'EXCLUDED';
|
|
|
|
public static function init() {
|
|
if ( class_exists( 'Argus_Settings' ) ) {
|
|
$custom_excludes = (array) Argus_Settings::get( 'cache_custom_exclude_paths', array() );
|
|
if ( ! empty( $custom_excludes ) ) {
|
|
add_filter(
|
|
'argus_wpd_cache_protected_paths',
|
|
function ( $paths ) use ( $custom_excludes ) {
|
|
return array_merge( $paths, $custom_excludes );
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
if ( ! Argus_Settings::get( 'static_cache_enabled', false ) ) {
|
|
return;
|
|
}
|
|
|
|
add_action( 'send_headers', array( __CLASS__, 'maybe_serve_cached' ), 0 );
|
|
|
|
foreach ( array( 'switch_theme', 'customize_save_after', 'activated_plugin', 'deactivated_plugin' ) as $hook ) {
|
|
add_action( $hook, array( __CLASS__, 'purge_all' ) );
|
|
}
|
|
add_action( 'save_post', array( __CLASS__, 'purge_related_to_post' ) );
|
|
add_action( 'deleted_post', array( __CLASS__, 'purge_related_to_post' ) );
|
|
add_action( 'comment_post', array( __CLASS__, 'purge_related_to_comment' ) );
|
|
add_action( 'wp_set_comment_status', array( __CLASS__, 'purge_related_to_comment' ) );
|
|
}
|
|
|
|
public static function maybe_serve_cached() {
|
|
$start = microtime( true );
|
|
$url = self::current_url();
|
|
|
|
$classification = Argus_Cache_Eligibility::classify_request( self::request_context() );
|
|
|
|
if ( self::SAFE_TO_CACHE_STATUS() !== $classification['status'] ) {
|
|
$log_status = Argus_Cache_Eligibility::FORCE_EXCLUDED === $classification['status'] ? self::STATUS_EXCLUDED : self::STATUS_BYPASS;
|
|
Argus_Cache_Log::record( $log_status, $url, $classification['reason'] );
|
|
return;
|
|
}
|
|
|
|
$key = self::cache_key( $url );
|
|
$paths = self::cache_paths( $key );
|
|
$ttl = (int) Argus_Settings::get( 'static_cache_ttl_secs', 3600 );
|
|
$grace = (int) Argus_Settings::get( 'cache_stale_grace_secs', 600 );
|
|
|
|
if ( file_exists( $paths['html'] ) ) {
|
|
$age = time() - filemtime( $paths['html'] );
|
|
|
|
if ( $age < $ttl ) {
|
|
self::serve_hit( $paths, $url, self::STATUS_HIT, $start );
|
|
return;
|
|
}
|
|
|
|
if ( $age < ( $ttl + $grace ) ) {
|
|
$lock = self::acquire_lock( $key );
|
|
if ( ! $lock ) {
|
|
self::serve_hit( $paths, $url, self::STATUS_STALE, $start );
|
|
return;
|
|
}
|
|
|
|
self::start_capture( $url, $key, $paths, self::STATUS_REVALIDATED, $start, $lock );
|
|
return;
|
|
}
|
|
}
|
|
|
|
$lock = self::acquire_lock( $key );
|
|
if ( ! $lock ) {
|
|
|
|
Argus_Cache_Log::record( self::STATUS_MISS, $url, 'Cache miss (another request is already regenerating this URL).', null );
|
|
return;
|
|
}
|
|
|
|
self::start_capture( $url, $key, $paths, self::STATUS_MISS, $start, $lock );
|
|
}
|
|
|
|
protected static function SAFE_TO_CACHE_STATUS() {
|
|
return Argus_Cache_Eligibility::SAFE_TO_CACHE;
|
|
}
|
|
|
|
protected static function start_capture( $url, $key, array $paths, $status, $start, $lock ) {
|
|
header( 'X-Argus-Cache: ' . $status );
|
|
ob_start(
|
|
function ( $html ) use ( $url, $key, $paths, $status, $start, $lock ) {
|
|
return self::capture_and_store( $html, $url, $key, $paths, $status, $start, $lock );
|
|
}
|
|
);
|
|
}
|
|
|
|
public static function capture_and_store( $html, $url, $key, array $paths, $status, $start, $lock ) {
|
|
$response_ms = (int) round( ( microtime( true ) - $start ) * 1000 );
|
|
|
|
$response_context = array(
|
|
'status_code' => http_response_code(),
|
|
'headers' => self::response_headers(),
|
|
'new_cookies_set' => self::response_cookie_names(),
|
|
'content_type' => self::response_content_type(),
|
|
);
|
|
|
|
$classification = Argus_Cache_Eligibility::classify_response( $response_context );
|
|
|
|
if ( Argus_Cache_Eligibility::SAFE_TO_CACHE !== $classification['status'] || '' === $html ) {
|
|
$log_status = '' === $html ? self::STATUS_BYPASS : ( Argus_Cache_Eligibility::FORCE_EXCLUDED === $classification['status'] ? self::STATUS_EXCLUDED : self::STATUS_BYPASS );
|
|
Argus_Cache_Log::record( $log_status, $url, '' === $html ? 'Empty response body.' : $classification['reason'], $response_ms );
|
|
self::release_lock( $lock );
|
|
return $html;
|
|
}
|
|
|
|
self::write_cache_entry( $paths, $html, $url );
|
|
Argus_Cache_Log::record( $status, $url, 'Stored.', $response_ms );
|
|
self::release_lock( $lock );
|
|
|
|
return $html;
|
|
}
|
|
|
|
protected static function write_cache_entry( array $paths, $html, $url ) {
|
|
wp_mkdir_p( dirname( $paths['html'] ) );
|
|
file_put_contents( $paths['html'], $html ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
|
|
$min_bytes = (int) Argus_Settings::get( 'cache_min_compress_bytes', 1024 );
|
|
if ( strlen( $html ) >= $min_bytes ) {
|
|
if ( Argus_Settings::get( 'cache_gzip_enabled', true ) && function_exists( 'gzencode' ) ) {
|
|
$level = (int) Argus_Settings::get( 'cache_gzip_level', 6 );
|
|
file_put_contents( $paths['gz'], gzencode( $html, max( 1, min( 9, $level ) ) ) ); // phpcs:ignore
|
|
} else {
|
|
@unlink( $paths['gz'] ); // phpcs:ignore
|
|
}
|
|
if ( Argus_Settings::get( 'cache_brotli_enabled', true ) && function_exists( 'brotli_compress' ) ) {
|
|
$level = (int) Argus_Settings::get( 'cache_brotli_level', 5 );
|
|
file_put_contents( $paths['br'], brotli_compress( $html, max( 0, min( 11, $level ) ) ) ); // phpcs:ignore
|
|
} else {
|
|
@unlink( $paths['br'] ); // phpcs:ignore
|
|
}
|
|
} else {
|
|
@unlink( $paths['gz'] ); // phpcs:ignore
|
|
@unlink( $paths['br'] ); // phpcs:ignore
|
|
}
|
|
|
|
file_put_contents( // phpcs:ignore
|
|
$paths['meta'],
|
|
wp_json_encode(
|
|
array(
|
|
'url' => $url,
|
|
'bytes' => strlen( $html ),
|
|
'created_at' => current_time( 'mysql', true ),
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
protected static function serve_hit( array $paths, $url, $status, $start ) {
|
|
$encoding = self::negotiate_encoding();
|
|
$source = $paths['html'];
|
|
$header_encoding = '';
|
|
|
|
if ( 'br' === $encoding && file_exists( $paths['br'] ) ) {
|
|
$source = $paths['br'];
|
|
$header_encoding = 'br';
|
|
} elseif ( 'gzip' === $encoding && file_exists( $paths['gz'] ) ) {
|
|
$source = $paths['gz'];
|
|
$header_encoding = 'gzip';
|
|
}
|
|
|
|
header( 'X-Argus-Cache: ' . $status );
|
|
header( 'X-Argus-Cache-Age: ' . ( time() - filemtime( $paths['html'] ) ) );
|
|
header( 'Vary: Accept-Encoding' );
|
|
if ( $header_encoding ) {
|
|
header( 'Content-Encoding: ' . $header_encoding );
|
|
}
|
|
header( 'Content-Type: text/html; charset=UTF-8' );
|
|
readfile( $source ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
|
|
Argus_Cache_Log::record( $status, $url, 'Served from cache.', (int) round( ( microtime( true ) - $start ) * 1000 ) );
|
|
exit;
|
|
}
|
|
|
|
protected static function negotiate_encoding() {
|
|
$accept = strtolower( (string) ( $_SERVER['HTTP_ACCEPT_ENCODING'] ?? '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
|
|
if ( false !== strpos( $accept, 'br' ) ) {
|
|
return 'br';
|
|
}
|
|
if ( false !== strpos( $accept, 'gzip' ) ) {
|
|
return 'gzip';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
protected static function request_context() {
|
|
$cookies = array();
|
|
foreach ( (array) $_COOKIE as $name => $value ) { // phpcs:ignore WordPress.Security.NonceVerification
|
|
$cookies[ (string) $name ] = '';
|
|
}
|
|
|
|
return array(
|
|
'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
|
|
'path' => (string) wp_parse_url( self::current_url(), PHP_URL_PATH ),
|
|
'query' => $_GET, // phpcs:ignore WordPress.Security.NonceVerification
|
|
'is_admin' => is_admin(),
|
|
'is_logged_in' => is_user_logged_in(),
|
|
'cookies' => $cookies,
|
|
'has_authorization_header' => ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) || ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ), // phpcs:ignore
|
|
'is_ajax' => wp_doing_ajax(),
|
|
'is_rest' => defined( 'REST_REQUEST' ) && REST_REQUEST,
|
|
'is_cron' => defined( 'DOING_CRON' ) && DOING_CRON,
|
|
) + self::conditional_tag_excludes();
|
|
}
|
|
|
|
protected static function conditional_tag_excludes() {
|
|
if ( is_feed() || is_trackback() || is_preview() || is_search() || is_404() ) {
|
|
return array( 'is_admin' => true );
|
|
}
|
|
return array();
|
|
}
|
|
|
|
protected static function response_headers() {
|
|
$out = array();
|
|
foreach ( headers_list() as $header ) {
|
|
$parts = explode( ':', $header, 2 );
|
|
if ( 2 !== count( $parts ) ) {
|
|
continue;
|
|
}
|
|
$name = strtolower( trim( $parts[0] ) );
|
|
if ( isset( $out[ $name ] ) ) {
|
|
$out[ $name ] .= ', ' . trim( $parts[1] );
|
|
} else {
|
|
$out[ $name ] = trim( $parts[1] );
|
|
}
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
protected static function response_cookie_names() {
|
|
$names = array();
|
|
foreach ( headers_list() as $header ) {
|
|
if ( 0 === stripos( $header, 'Set-Cookie:' ) && preg_match( '/^Set-Cookie:\s*([^=]+)=/i', $header, $m ) ) {
|
|
$names[] = trim( $m[1] );
|
|
}
|
|
}
|
|
return $names;
|
|
}
|
|
|
|
protected static function response_content_type() {
|
|
foreach ( headers_list() as $header ) {
|
|
if ( 0 === stripos( $header, 'Content-Type:' ) ) {
|
|
return trim( substr( $header, strlen( 'Content-Type:' ) ) );
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
protected static function current_url() {
|
|
$scheme = is_ssl() ? 'https' : 'http';
|
|
$host = (string) ( $_SERVER['HTTP_HOST'] ?? 'default' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
|
|
$uri = (string) ( $_SERVER['REQUEST_URI'] ?? '/' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
|
|
return $scheme . '://' . $host . $uri;
|
|
}
|
|
|
|
public static function cache_key( $url ) {
|
|
$parts = wp_parse_url( $url );
|
|
$path = $parts['path'] ?? '/';
|
|
$query = array();
|
|
if ( ! empty( $parts['query'] ) ) {
|
|
parse_str( $parts['query'], $query );
|
|
$query = array_diff_key( $query, array_flip( Argus_Cache_Eligibility::IGNORABLE_QUERY_PARAMS ) );
|
|
ksort( $query );
|
|
}
|
|
$host = (string) ( $parts['host'] ?? 'default' );
|
|
return md5( $host . '|' . $path . '|' . http_build_query( $query ) );
|
|
}
|
|
|
|
protected static function cache_dir() {
|
|
$uploads = wp_get_upload_dir();
|
|
return trailingslashit( $uploads['basedir'] ) . 'argus-wpd-data/html-cache';
|
|
}
|
|
|
|
public static function cache_paths( $key ) {
|
|
$base = trailingslashit( self::cache_dir() ) . $key;
|
|
return array(
|
|
'html' => $base . '.html',
|
|
'gz' => $base . '.html.gz',
|
|
'br' => $base . '.html.br',
|
|
'meta' => $base . '.meta.json',
|
|
);
|
|
}
|
|
|
|
protected static function lock_dir() {
|
|
return trailingslashit( self::cache_dir() ) . 'locks';
|
|
}
|
|
|
|
protected static function acquire_lock( $key ) {
|
|
$dir = self::lock_dir();
|
|
if ( ! is_dir( $dir ) ) {
|
|
wp_mkdir_p( $dir );
|
|
}
|
|
$path = trailingslashit( $dir ) . $key . '.lock';
|
|
$fh = @fopen( $path, 'c' ); // phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors
|
|
if ( ! $fh ) {
|
|
return false;
|
|
}
|
|
if ( ! flock( $fh, LOCK_EX | LOCK_NB ) ) {
|
|
fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
return false;
|
|
}
|
|
return $fh;
|
|
}
|
|
|
|
protected static function release_lock( $fh ) {
|
|
if ( $fh ) {
|
|
flock( $fh, LOCK_UN );
|
|
fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
}
|
|
}
|
|
|
|
public static function purge_all() {
|
|
$dir = self::cache_dir();
|
|
if ( ! is_dir( $dir ) ) {
|
|
return 0;
|
|
}
|
|
$count = 0;
|
|
foreach ( array( '*.html', '*.html.gz', '*.html.br', '*.meta.json' ) as $glob ) {
|
|
foreach ( glob( trailingslashit( $dir ) . $glob ) ?: array() as $file ) {
|
|
@unlink( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
|
++$count;
|
|
}
|
|
}
|
|
Argus_Cache_Log::record( self::STATUS_PURGED, '*', 'Purge everything.' );
|
|
return $count;
|
|
}
|
|
|
|
public static function purge_url( $url ) {
|
|
$key = self::cache_key( $url );
|
|
foreach ( self::cache_paths( $key ) as $path ) {
|
|
@unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
|
}
|
|
Argus_Cache_Log::record( self::STATUS_PURGED, $url, 'Purge single URL.' );
|
|
}
|
|
|
|
public static function purge_pattern( $pattern ) {
|
|
$dir = self::cache_dir();
|
|
if ( ! is_dir( $dir ) ) {
|
|
return 0;
|
|
}
|
|
$regex = '/^' . str_replace( '\*', '.*', preg_quote( $pattern, '/' ) ) . '$/i';
|
|
$count = 0;
|
|
foreach ( glob( trailingslashit( $dir ) . '*.meta.json' ) ?: array() as $meta_file ) {
|
|
$meta = json_decode( (string) file_get_contents( $meta_file ), true ); // phpcs:ignore
|
|
if ( ! is_array( $meta ) || empty( $meta['url'] ) || ! preg_match( $regex, $meta['url'] ) ) {
|
|
continue;
|
|
}
|
|
$key = basename( $meta_file, '.meta.json' );
|
|
foreach ( self::cache_paths( $key ) as $path ) {
|
|
@unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
|
}
|
|
++$count;
|
|
}
|
|
Argus_Cache_Log::record( self::STATUS_PURGED, $pattern, 'Purge by pattern (' . $count . ' entries).' );
|
|
return $count;
|
|
}
|
|
|
|
public static function purge_related_to_post( $post_id ) {
|
|
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
|
|
return;
|
|
}
|
|
$permalink = get_permalink( $post_id );
|
|
if ( $permalink ) {
|
|
self::purge_url( $permalink );
|
|
}
|
|
self::purge_url( home_url( '/' ) );
|
|
$page_for_posts = (int) get_option( 'page_for_posts' );
|
|
if ( $page_for_posts ) {
|
|
self::purge_url( get_permalink( $page_for_posts ) );
|
|
}
|
|
}
|
|
|
|
public static function purge_related_to_comment( $comment_id ) {
|
|
$comment = get_comment( $comment_id );
|
|
if ( $comment && $comment->comment_post_ID ) {
|
|
self::purge_related_to_post( (int) $comment->comment_post_ID );
|
|
}
|
|
}
|
|
|
|
public static function stats() {
|
|
$dir = self::cache_dir();
|
|
if ( ! is_dir( $dir ) ) {
|
|
return array( 'file_count' => 0, 'total_bytes' => 0 );
|
|
}
|
|
$files = glob( trailingslashit( $dir ) . '*.html' ) ?: array();
|
|
$bytes = 0;
|
|
foreach ( $files as $file ) {
|
|
$bytes += filesize( $file );
|
|
foreach ( array( '.gz', '.br' ) as $ext ) {
|
|
$variant = $file . $ext;
|
|
if ( file_exists( $variant ) ) {
|
|
$bytes += filesize( $variant );
|
|
}
|
|
}
|
|
}
|
|
return array( 'file_count' => count( $files ), 'total_bytes' => $bytes );
|
|
}
|
|
|
|
public static function compression_stats() {
|
|
$dir = self::cache_dir();
|
|
$out = array(
|
|
'gzip' => array( 'files' => 0, 'avg_savings_pct' => 0.0, 'bytes_saved' => 0 ),
|
|
'brotli' => array( 'files' => 0, 'avg_savings_pct' => 0.0, 'bytes_saved' => 0 ),
|
|
'total_bytes_saved' => 0,
|
|
);
|
|
if ( ! is_dir( $dir ) ) {
|
|
return $out;
|
|
}
|
|
|
|
$files = glob( trailingslashit( $dir ) . '*.html' ) ?: array();
|
|
$pct_sums = array( 'gzip' => 0.0, 'brotli' => 0.0 );
|
|
|
|
foreach ( $files as $file ) {
|
|
$plain_size = filesize( $file );
|
|
if ( ! $plain_size ) {
|
|
continue;
|
|
}
|
|
foreach ( array( 'gzip' => '.gz', 'brotli' => '.br' ) as $format => $ext ) {
|
|
$variant = $file . $ext;
|
|
if ( ! file_exists( $variant ) ) {
|
|
continue;
|
|
}
|
|
$variant_size = filesize( $variant );
|
|
if ( ! $variant_size || $variant_size >= $plain_size ) {
|
|
continue;
|
|
}
|
|
$saved = $plain_size - $variant_size;
|
|
++$out[ $format ]['files'];
|
|
$out[ $format ]['bytes_saved'] += $saved;
|
|
$out['total_bytes_saved'] += $saved;
|
|
$pct_sums[ $format ] += ( $saved / $plain_size ) * 100;
|
|
}
|
|
}
|
|
|
|
foreach ( array( 'gzip', 'brotli' ) as $format ) {
|
|
if ( $out[ $format ]['files'] > 0 ) {
|
|
$out[ $format ]['avg_savings_pct'] = round( $pct_sums[ $format ] / $out[ $format ]['files'], 1 );
|
|
}
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
public static function list_cached( $limit = 100 ) {
|
|
$dir = self::cache_dir();
|
|
if ( ! is_dir( $dir ) ) {
|
|
return array();
|
|
}
|
|
$meta_files = glob( trailingslashit( $dir ) . '*.meta.json' ) ?: array();
|
|
usort(
|
|
$meta_files,
|
|
function ( $a, $b ) {
|
|
return filemtime( $b ) <=> filemtime( $a );
|
|
}
|
|
);
|
|
|
|
$out = array();
|
|
foreach ( array_slice( $meta_files, 0, $limit ) as $meta_file ) {
|
|
$meta = json_decode( (string) file_get_contents( $meta_file ), true ); // phpcs:ignore
|
|
if ( ! is_array( $meta ) ) {
|
|
continue;
|
|
}
|
|
$key = basename( $meta_file, '.meta.json' );
|
|
$paths = self::cache_paths( $key );
|
|
$out[] = array(
|
|
'url' => $meta['url'] ?? '',
|
|
'bytes' => (int) ( $meta['bytes'] ?? 0 ),
|
|
'created_at' => $meta['created_at'] ?? '',
|
|
'age_secs' => file_exists( $paths['html'] ) ? ( time() - filemtime( $paths['html'] ) ) : null,
|
|
'has_gzip' => file_exists( $paths['gz'] ),
|
|
'has_brotli' => file_exists( $paths['br'] ),
|
|
);
|
|
}
|
|
return $out;
|
|
}
|
|
}
|