diff --git a/WPS-Cache/includes/advanced-cache-template.php b/WPS-Cache/includes/advanced-cache-template.php index 4c95566..b80661b 100644 --- a/WPS-Cache/includes/advanced-cache-template.php +++ b/WPS-Cache/includes/advanced-cache-template.php @@ -4,6 +4,7 @@ * WPS Cache - Advanced Cache Drop-in * Supports Query Strings via hashed filenames. * Supports Mobile Cache Separation. + * Supports pre-compressed Gzip serving. */ if (!defined("ABSPATH")) { @@ -16,8 +17,23 @@ class WPSAdvancedCache { - private const CACHE_LIFETIME = 3600; - private const COOKIE_HEADER = "wordpress_logged_in_"; + // PHP 8.3: typed class constants + private const int DEFAULT_CACHE_LIFETIME = 3600; + private const string COOKIE_HEADER = "wordpress_logged_in_"; + + private int $cacheLifetime; + + public function __construct() + { + // Load plugin-written config so the drop-in respects the admin setting + // without bootstrapping WordPress. + $configFile = WP_CONTENT_DIR . "/cache/wps-cache/config.php"; + $config = is_file($configFile) ? (@include $configFile) : []; + $config = is_array($config) ? $config : []; + $this->cacheLifetime = isset($config["cache_lifetime"]) + ? (int) $config["cache_lifetime"] + : self::DEFAULT_CACHE_LIFETIME; + } public function execute(): void { @@ -27,9 +43,10 @@ public function execute(): void $file = $this->getCacheFilePath(); - if (file_exists($file)) { + // is_file() is faster than file_exists() – it skips the directory check. + if (is_file($file)) { $mtime = filemtime($file); - if (time() - $mtime > self::CACHE_LIFETIME) { + if (time() - $mtime > $this->cacheLifetime) { return; } $this->serve($file, $mtime); @@ -46,9 +63,10 @@ private function shouldBypass(): bool // We now rely on the file existence check. // If query params exist but no file matches the hash, it falls through to WP. - foreach ($_COOKIE as $key => $value) { + foreach ($_COOKIE as $key => $_value) { + // PHP 8.0+: str_starts_with() avoids the strpos() === 0 idiom. if ( - strpos($key, self::COOKIE_HEADER) === 0 || + str_starts_with($key, self::COOKIE_HEADER) || $key === "wp-postpass_" || $key === "comment_author_" ) { @@ -58,10 +76,8 @@ private function shouldBypass(): bool // Special paths $uri = $_SERVER["REQUEST_URI"] ?? "/"; - if ( - strpos($uri, "/wp-admin") !== false || - strpos($uri, "/xmlrpc.php") !== false - ) { + // PHP 8.0+: str_contains() is cleaner and avoids !== false checks. + if (str_contains($uri, "/wp-admin") || str_contains($uri, "/xmlrpc.php")) { return true; } @@ -75,62 +91,39 @@ private function getCacheFilePath(): string "", $_SERVER["HTTP_HOST"] ?? "unknown", ); - $uri = $_SERVER["REQUEST_URI"] ?? "/"; - - $path = parse_url($uri, PHP_URL_PATH); - // $path = str_replace("..", "", $path); // Security - $path = $this->sanitizePath($path); + $uri = $_SERVER["REQUEST_URI"] ?? "/"; + $path = $this->sanitizePath(parse_url($uri, PHP_URL_PATH) ?: "/"); - if ( - substr($path, -1) !== "/" && - !preg_match('/\.[a-z0-9]{2,4}$/i', $path) - ) { + // PHP 8.0+: str_ends_with() instead of substr($path, -1) !== "/". + if (!str_ends_with($path, "/") && !preg_match('/\.[a-z0-9]{2,4}$/i', $path)) { $path .= "/"; } - // Determine Mobile Suffix $suffix = $this->getMobileSuffix(); - // Determine Filename $query = parse_url($uri, PHP_URL_QUERY); if ($query) { parse_str($query, $queryParams); ksort($queryParams); - // Append suffix to query-string based filenames - $filename = - "index" . - $suffix . - "-" . - md5(http_build_query($queryParams)) . - ".html"; + $filename = "index" . $suffix . "-" . md5(http_build_query($queryParams)) . ".html"; } else { - // Append suffix to standard filenames $filename = "index" . $suffix . ".html"; } - return WP_CONTENT_DIR . - "/cache/wps-cache/html/" . - $host . - $path . - $filename; + return WP_CONTENT_DIR . "/cache/wps-cache/html/" . $host . $path . $filename; } /** * Efficiently detects mobile devices based on User-Agent. - * Must match logic in HTMLCache.php + * Must match logic in HTMLCache.php. */ private function getMobileSuffix(): string { $ua = $_SERVER["HTTP_USER_AGENT"] ?? ""; - if (empty($ua)) { + if ($ua === "") { return ""; } - if ( - preg_match( - "/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i", - $ua, - ) - ) { + if (preg_match("/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i", $ua)) { return "-mobile"; } return ""; @@ -138,8 +131,8 @@ private function getMobileSuffix(): string private function sanitizePath(string $path): string { - $path = str_replace(chr(0), "", $path); - $parts = explode("/", $path); + $path = str_replace(chr(0), "", $path); + $parts = explode("/", $path); $safeParts = []; foreach ($parts as $part) { if ($part === "" || $part === ".") { @@ -156,7 +149,12 @@ private function sanitizePath(string $path): string private function serve(string $file, int $mtime): void { - $etag = '"' . $mtime . '"'; + $fileSize = (int) filesize($file); + // Apache-style ETag combining last-modified time and file size. + $etag = sprintf('"%x-%x"', $mtime, $fileSize); + $lastModified = gmdate("D, d M Y H:i:s", $mtime) . " GMT"; + + // ETag-based conditional request (strong validator). if ( isset($_SERVER["HTTP_IF_NONE_MATCH"]) && trim($_SERVER["HTTP_IF_NONE_MATCH"]) === $etag @@ -165,14 +163,47 @@ private function serve(string $file, int $mtime): void exit(); } + // Date-based conditional request (weak validator). + if (isset($_SERVER["HTTP_IF_MODIFIED_SINCE"])) { + $ims = strtotime($_SERVER["HTTP_IF_MODIFIED_SINCE"]); + if ($ims !== false && $mtime < $ims) { + header("HTTP/1.1 304 Not Modified"); + exit(); + } + } + + // Serve a pre-compressed gzip file when the client accepts it and the + // file exists. This eliminates on-the-fly compression overhead. + $acceptEncoding = $_SERVER["HTTP_ACCEPT_ENCODING"] ?? ""; + $gzFile = $file . ".gz"; + $useGzip = str_contains($acceptEncoding, "gzip") && is_file($gzFile); + + // Prevent the web server / PHP from double-compressing the response. + if ($useGzip && function_exists("ini_set")) { + ini_set("zlib.output_compression", "0"); + } + header("Content-Type: text/html; charset=UTF-8"); - header("Cache-Control: public, max-age=3600"); + header("Cache-Control: public, max-age=" . $this->cacheLifetime); header("ETag: " . $etag); + header("Last-Modified: " . $lastModified); + header("Expires: " . gmdate("D, d M Y H:i:s", time() + $this->cacheLifetime) . " GMT"); + // Always advertise Vary so CDNs/proxies store separate copies per encoding. + header("Vary: Accept-Encoding"); header("X-WPS-Cache: HIT"); - readfile($file); + if ($useGzip) { + $gzSize = (int) filesize($gzFile); + header("Content-Encoding: gzip"); + header("Content-Length: " . $gzSize); + readfile($gzFile); + } else { + header("Content-Length: " . $fileSize); + readfile($file); + } + exit(); } } -new WPSAdvancedCache()->execute(); +(new WPSAdvancedCache())->execute(); diff --git a/WPS-Cache/src/Cache/Drivers/AbstractCacheDriver.php b/WPS-Cache/src/Cache/Drivers/AbstractCacheDriver.php index 4d7442e..311cd78 100644 --- a/WPS-Cache/src/Cache/Drivers/AbstractCacheDriver.php +++ b/WPS-Cache/src/Cache/Drivers/AbstractCacheDriver.php @@ -94,7 +94,7 @@ protected function atomicWrite(string $filepath, string $content): bool return false; } - if (file_put_contents($temp_file, $content) === false) { + if (file_put_contents($temp_file, $content, LOCK_EX) === false) { $this->logError("Failed to write content to $temp_file"); @unlink($temp_file); return false; diff --git a/WPS-Cache/src/Cache/Drivers/HTMLCache.php b/WPS-Cache/src/Cache/Drivers/HTMLCache.php index e1ec89e..6ec291a 100644 --- a/WPS-Cache/src/Cache/Drivers/HTMLCache.php +++ b/WPS-Cache/src/Cache/Drivers/HTMLCache.php @@ -21,7 +21,8 @@ final class HTMLCache extends AbstractCacheDriver private ?CommerceManager $commerceManager; // Optimization: Use hash map for O(1) lookups - private const BYPASS_PARAMS = [ + // PHP 8.3: typed class constants + private const array BYPASS_PARAMS = [ "add-to-cart" => true, "wp_nonce" => true, "preview" => true, @@ -29,12 +30,12 @@ final class HTMLCache extends AbstractCacheDriver ]; // Sentinel: Limits to prevent Cache DoS (Disk Exhaustion) - private const MAX_QUERY_LEN = 512; - private const MAX_QUERY_PARAMS = 10; + private const int MAX_QUERY_LEN = 512; + private const int MAX_QUERY_PARAMS = 10; // SOTA: Explicitly ignore static extensions to prevent "Soft 404" caching // Optimization: Use hash map for O(1) lookups - private const IGNORED_EXTENSIONS = [ + private const array IGNORED_EXTENSIONS = [ "xml" => true, "json" => true, "map" => true, @@ -161,6 +162,18 @@ public function processOutput(string $buffer): string return $buffer; } + // Only cache actual HTML responses; skip JSON/XML/feeds etc. + $contentType = ""; + foreach (headers_list() as $h) { + if (stripos($h, "Content-Type:") === 0) { + $contentType = $h; + break; + } + } + if ($contentType !== "" && stripos($contentType, "text/html") === false) { + return $buffer; + } + // --- PHASE 1: DOM MANIPULATION (Robust & Safe) --- $useDomPipeline = !empty($this->settings["remove_unused_css"]) || @@ -170,7 +183,9 @@ public function processOutput(string $buffer): string $content = $buffer; if ($useDomPipeline) { - libxml_use_internal_errors(true); + // Save and restore the previous libxml error-handling state so we + // don't permanently silence errors for the rest of the request. + $prevLibxmlErrors = libxml_use_internal_errors(true); $dom = new DOMDocument(); // Hack: force UTF-8 @$dom->loadHTML( @@ -178,6 +193,7 @@ public function processOutput(string $buffer): string LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD, ); libxml_clear_errors(); + libxml_use_internal_errors($prevLibxmlErrors); // 1. Remove Unused CSS (DOM) if (!empty($this->settings["remove_unused_css"])) { @@ -227,6 +243,11 @@ public function processOutput(string $buffer): string } catch (\Throwable $e) { } + // 6. HTML Minification – strip HTML comments and collapse inter-tag + // whitespace. Done after all other processors to avoid interfering + // with DOM-based pipelines above. + $content = $this->minifyHtml($content); + // Add Timestamp & Signature $deviceType = $this->getMobileSuffix() ? "Mobile" : "Desktop"; $content .= sprintf( @@ -240,6 +261,35 @@ public function processOutput(string $buffer): string return $content; } + /** + * Lightweight HTML minifier. + * + * - Strips HTML comments (preserves IE conditional comments). + * - Collapses whitespace-only gaps between block-level elements. + * + * Intentionally conservative: it does NOT touch content inside + *
,