From e792526122cf58227a65d811876ba300a4a7a8f1 Mon Sep 17 00:00:00 2001 From: faisalahammad Date: Sun, 9 Aug 2026 19:39:46 +0600 Subject: [PATCH] fix(cache): add max folder size guard for wp-content/litespeed (#777) - New site-level setting 'Maximum Cache Folder Size' (MB) under Cache -> Purge - 15-min cron task (litespeed_task_folder_size) measures regenerable asset subdirs (css, js, ucss, ccss, optimax, lqip, avatar, localres) and prunes oldest-first down to a 90% hysteresis target - Default 0 keeps the cron as a no-op (BC). Setting reduced to 0 clears the schedule via the standard _conf_cron / Task::try_clean path - Multisite-safe: the recursive iterator walks all blog_id subfolders - Skips debug, cloud, auto-backup, crawler subdirs and root files (.litespeed_conf.dat, .htaccess, robots.txt). WebP/AVIF live in uploads/ and are not affected - Recreates .htaccess via File::ensure_static_protection() if the asset tree is fully cleared Fixes #777 --- autoload.php | 1 + data/const.default.json | 1 + readme.txt | 1 + src/base.cls.php | 4 +- src/folder.cls.php | 156 +++++++++++++++++++++++++++++++ src/lang.cls.php | 1 + src/task.cls.php | 4 + tpl/cache/settings-purge.tpl.php | 14 +++ 8 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 src/folder.cls.php diff --git a/autoload.php b/autoload.php index 05c54daa6..2f949f24b 100644 --- a/autoload.php +++ b/autoload.php @@ -47,6 +47,7 @@ 'src/error.cls.php', 'src/esi.cls.php', 'src/file.cls.php', + 'src/folder.cls.php', 'src/guest.cls.php', 'src/gui.cls.php', 'src/health.cls.php', diff --git a/data/const.default.json b/data/const.default.json index 9d7141f1f..eb78a4bc1 100644 --- a/data/const.default.json +++ b/data/const.default.json @@ -162,6 +162,7 @@ "misc-heartbeat_back_ttl": "60", "misc-heartbeat_editor": "", "misc-heartbeat_editor_ttl": "15", + "misc-max_folder_size": "", "cdn": "", "cdn-attr": ".src\n.data-src\n.href\n.poster\nsource.srcset", "cdn-ori": "", diff --git a/readme.txt b/readme.txt index f104208c1..b0e93e744 100644 --- a/readme.txt +++ b/readme.txt @@ -259,6 +259,7 @@ You can report security bugs through the Patchstack Vulnerability Disclosure Pro = 8.0 - Coming soon 2026 = * 🌱**OptiMax** OptiMax to maximize the page score. +* **Cache** Added a maximum cache folder size limit (wp-content/litespeed) with automatic oldest-first pruning to protect the disk from bot-traffic growth. (#777) = 7.9.1 - Aug 18 2026 = * **Core** Aligned the runtime PHP and WordPress guards with the published minimum requirements. diff --git a/src/base.cls.php b/src/base.cls.php index d24312e46..468ba381f 100644 --- a/src/base.cls.php +++ b/src/base.cls.php @@ -264,6 +264,7 @@ class Base extends Root { const O_MISC_HEARTBEAT_BACK_TTL = 'misc-heartbeat_back_ttl'; const O_MISC_HEARTBEAT_EDITOR = 'misc-heartbeat_editor'; const O_MISC_HEARTBEAT_EDITOR_TTL = 'misc-heartbeat_editor_ttl'; + const O_MISC_MAX_FOLDER_SIZE = 'misc-max_folder_size'; // -------------------------------------------------- ## // -------------- CDN ----------------- ## @@ -550,6 +551,7 @@ class Base extends Root { self::O_MISC_HEARTBEAT_BACK_TTL => 0, self::O_MISC_HEARTBEAT_EDITOR => false, self::O_MISC_HEARTBEAT_EDITOR_TTL => 0, + self::O_MISC_MAX_FOLDER_SIZE => 0, // CDN self::O_CDN => false, @@ -1018,7 +1020,7 @@ protected function _conf_pswd( $id ) { * @return bool */ protected function _conf_cron( $id ) { - $check_ids = [ self::O_OPTM_CSS_ASYNC, self::O_MEDIA_PLACEHOLDER_RESP_ASYNC, self::O_DISCUSS_AVATAR_CRON, self::O_IMG_OPTM_AUTO, self::O_CRAWLER ]; + $check_ids = [ self::O_OPTM_CSS_ASYNC, self::O_MEDIA_PLACEHOLDER_RESP_ASYNC, self::O_DISCUSS_AVATAR_CRON, self::O_IMG_OPTM_AUTO, self::O_CRAWLER, self::O_MISC_MAX_FOLDER_SIZE ]; return in_array( $id, $check_ids, true ); } diff --git a/src/folder.cls.php b/src/folder.cls.php new file mode 100644 index 000000000..435fb0ee1 --- /dev/null +++ b/src/folder.cls.php @@ -0,0 +1,156 @@ +conf( self::O_MISC_MAX_FOLDER_SIZE ); + if ( $max_mb <= 0 ) { + return; + } + + if ( ! defined( 'LITESPEED_STATIC_DIR' ) || ! is_dir( LITESPEED_STATIC_DIR ) ) { + return; + } + + $max_bytes = $max_mb * 1000000; + + $entries = self::_collect_files(); + if ( empty( $entries ) ) { + return; + } + + $total = 0; + foreach ( $entries as $e ) { + $total += $e['size']; + } + + $target = (int) ( $max_bytes * 0.9 ); + if ( $total <= $target ) { + return; + } + + usort( $entries, function ( $a, $b ) { + return $a['mtime'] <=> $b['mtime']; + } ); + + $over = $total - $target; + $deleted = 0; + $reclaimed = 0; + foreach ( $entries as $e ) { + if ( $over <= 0 || $deleted >= self::PER_RUN_DELETE_LIMIT ) { + break; + } + if ( ! is_file( $e['path'] ) ) { + continue; + } + + wp_delete_file( $e['path'] ); + if ( ! is_file( $e['path'] ) ) { + $over -= $e['size']; + $reclaimed += $e['size']; + ++$deleted; + } + } + + if ( $deleted > 0 ) { + self::debug( sprintf( + 'Pruned %d file(s) to enforce %d MB limit (reclaimed %s, total before %s).', + $deleted, + $max_mb, + Utility::real_size( $reclaimed ), + Utility::real_size( $total ) + ) ); + + // Recreate .htaccess if all asset files were wiped. + File::ensure_static_protection(); + } + } + + /** + * Walk the regenerable asset subdirs and collect file metadata for + * size accounting and prune ordering. Multisite-safe (walks per-blog + * subfolders via the recursive iterator). + * + * @since 8.0 + * + * @return array + */ + private static function _collect_files() { + $entries = []; + + foreach ( self::ASSET_SUBDIRS as $subdir ) { + $base = LITESPEED_STATIC_DIR . '/' . $subdir; + if ( ! is_dir( $base ) ) { + continue; + } + + try { + $iter = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $base, \FilesystemIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::LEAVES_ONLY, + \RecursiveIteratorIterator::CATCH_GET_CHILD + ); + } catch ( \Throwable $e ) { + self::debug( 'Skip unreadable subdir [subdir] ' . $subdir ); + continue; + } + + try { + foreach ( $iter as $file ) { + if ( ! $file->isFile() ) { + continue; + } + $entries[] = [ + 'path' => $file->getPathname(), + 'mtime' => $file->getMTime(), + 'size' => (int) $file->getSize(), + ]; + } + } catch ( \Throwable $e ) { + self::debug( 'Aborted walking subdir [subdir] ' . $subdir ); + } + } + + return $entries; + } +} diff --git a/src/lang.cls.php b/src/lang.cls.php index 7ff654c99..2e3ed0027 100644 --- a/src/lang.cls.php +++ b/src/lang.cls.php @@ -249,6 +249,7 @@ public static function title( $id ) { self::O_MISC_HEARTBEAT_BACK_TTL => __( 'Backend Heartbeat TTL', 'litespeed-cache' ), self::O_MISC_HEARTBEAT_EDITOR => __( 'Editor Heartbeat', 'litespeed-cache' ), self::O_MISC_HEARTBEAT_EDITOR_TTL => __( 'Editor Heartbeat TTL', 'litespeed-cache' ), + self::O_MISC_MAX_FOLDER_SIZE => __( 'Maximum Cache Folder Size', 'litespeed-cache' ), self::O_CDN => __( 'Use CDN Mapping', 'litespeed-cache' ), self::CDN_MAPPING_URL => __( 'CDN URL', 'litespeed-cache' ), diff --git a/src/task.cls.php b/src/task.cls.php index 2e2e18507..7a40da8b4 100644 --- a/src/task.cls.php +++ b/src/task.cls.php @@ -68,6 +68,10 @@ class Task extends Root { 'name' => 'litespeed_task_crawler', 'hook' => 'LiteSpeed\Crawler::start_async_cron', ], // Set crawler to last one to use above results + Base::O_MISC_MAX_FOLDER_SIZE => [ + 'name' => 'litespeed_task_folder_size', + 'hook' => 'LiteSpeed\Folder::cron', + ], ]; /** diff --git a/tpl/cache/settings-purge.tpl.php b/tpl/cache/settings-purge.tpl.php index cdc595fd0..71db75718 100644 --- a/tpl/cache/settings-purge.tpl.php +++ b/tpl/cache/settings-purge.tpl.php @@ -155,4 +155,18 @@ + + + + title( $option_id ); ?> + + + build_input( $option_id, 'litespeed-input-short' ); ?> +
+ +
+ _validate_ttl( $option_id, 1, 100000, true ); ?> + + +