Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 79 additions & 48 deletions WPS-Cache/includes/advanced-cache-template.php
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand All @@ -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
{
Expand All @@ -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);
Expand All @@ -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_"
) {
Expand All @@ -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;
}

Expand All @@ -75,71 +91,48 @@ 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 "";
}

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 === ".") {
Expand All @@ -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
Expand All @@ -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();
2 changes: 1 addition & 1 deletion WPS-Cache/src/Cache/Drivers/AbstractCacheDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
81 changes: 71 additions & 10 deletions WPS-Cache/src/Cache/Drivers/HTMLCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,21 @@ 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,
"s" => true,
];

// 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,
Expand Down Expand Up @@ -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"]) ||
Expand All @@ -170,14 +183,17 @@ 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(
'<?xml encoding="utf-8" ?>' . $content,
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"])) {
Expand Down Expand Up @@ -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(
Expand All @@ -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
* <pre>, <script>, <style>, or <textarea> to avoid breaking those blocks.
*/
private function minifyHtml(string $html): string
{
// 1. Remove HTML comments except IE conditionals (<!--[if …]> … <![endif]-->)
$result = preg_replace('/<!--(?!\[if\s)[\s\S]*?-->/u', '', $html);
// Guard: if regex failed (e.g. invalid UTF-8), keep original.
if ($result !== null) {
$html = $result;
}

// 2. Collapse runs of whitespace between tags to a single newline.
// Using \s+ rather than a more aggressive trim to preserve
// inline-element spacing.
$result = preg_replace('/>\s{2,}</u', ">\n<", $html);
if ($result !== null) {
$html = $result;
}

return $html;
}

private function writeCacheFile(string $content): void
{
$host = $_SERVER["HTTP_HOST"] ?? "unknown";
Expand All @@ -252,18 +302,18 @@ private function writeCacheFile(string $content): void
$host = "unknown";
}

$uri = $_SERVER["REQUEST_URI"] ?? "/";
$uri = $_SERVER["REQUEST_URI"] ?? "/";
$path = $this->sanitizePath(parse_url($uri, PHP_URL_PATH));

if (
substr($path, -1) !== "/" &&
!str_ends_with($path, "/") &&
!preg_match('/\.[a-z0-9]{2,4}$/i', $path)
) {
$path .= "/";
}

$suffix = $this->getMobileSuffix();
$query = parse_url($uri, PHP_URL_QUERY);
$query = parse_url($uri, PHP_URL_QUERY);

if ($query) {
parse_str($query, $queryParams);
Expand All @@ -279,11 +329,22 @@ private function writeCacheFile(string $content): void
}

$fullPath = $this->cacheDir . $host . $path;
if (substr($fullPath, -1) !== "/") {
if (!str_ends_with($fullPath, "/")) {
$fullPath .= "/";
}

$this->atomicWrite($fullPath . $filename, $content);
$htmlFile = $fullPath . $filename;
$this->atomicWrite($htmlFile, $content);

// Write a pre-compressed gzip version alongside the plain file so the
// advanced-cache drop-in can serve it directly, eliminating per-request
// compression overhead entirely.
if (function_exists("gzencode")) {
$compressed = gzencode($content, 6);
if ($compressed !== false) {
$this->atomicWrite($htmlFile . ".gz", $compressed);
}
}
}

private function getMobileSuffix(): string
Expand Down
Loading