ARGUS WordPress Defence 1.0.0 — first production release
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.
This commit is contained in:
@@ -0,0 +1,495 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user