Skip to content
Open
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
1 change: 1 addition & 0 deletions autoload.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions data/const.default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand Down
1 change: 1 addition & 0 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion src/base.cls.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------------- ##
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 );
}
Expand Down
156 changes: 156 additions & 0 deletions src/folder.cls.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<?php
/**
* Cache folder size guard.
*
* @since 8.0
* @package LiteSpeed
*/

namespace LiteSpeed;

defined( 'WPINC' ) || exit();

/**
* Enforce a maximum size for the regenerable cache-asset subdirs under
* wp-content/litespeed/. Triggered by WP cron via Task; the actual check runs
* oldest-first across the asset subdirs and prunes until the directory is
* under a 90% hysteresis target.
*
* @since 8.0
*/
class Folder extends Base {

const LOG_TAG = '[Folder]';

/**
* Subdirs that hold regenerable cache assets and are safe to prune.
*
* @var string[]
*/
const ASSET_SUBDIRS = [ 'css', 'js', 'ucss', 'ccss', 'optimax', 'lqip', 'avatar', 'localres' ];

/**
* Per-run cap to avoid hogging a single cron tick on pathological dirs.
*
* @var int
*/
const PER_RUN_DELETE_LIMIT = 5000;

/**
* Cron entry point — measure asset subdirs and prune oldest-first when
* over the configured size budget.
*
* @since 8.0
*/
public static function cron() {
$max_mb = (int) self::cls()->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<int,array{path:string,mtime:int,size:int}>
*/
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;
}
}
1 change: 1 addition & 0 deletions src/lang.cls.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ),
Expand Down
4 changes: 4 additions & 0 deletions src/task.cls.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
];

/**
Expand Down
14 changes: 14 additions & 0 deletions tpl/cache/settings-purge.tpl.php
Original file line number Diff line number Diff line change
Expand Up @@ -155,4 +155,18 @@
</td>
</tr>

<tr>
<th>
<?php $option_id = Base::O_MISC_MAX_FOLDER_SIZE; ?>
<?php $this->title( $option_id ); ?>
</th>
<td>
<?php $this->build_input( $option_id, 'litespeed-input-short' ); ?> <?php esc_html_e( 'MB', 'litespeed-cache' ); ?>
<div class="litespeed-desc">
<?php esc_html_e( 'Maximum total size of the regenerable cache files under wp-content/litespeed/. When exceeded, the oldest files are removed automatically. 0 disables this limit.', 'litespeed-cache' ); ?>
</div>
<?php $this->_validate_ttl( $option_id, 1, 100000, true ); ?>
</td>
</tr>

</tbody></table>
Loading