* The plugin cache-control class for X-LiteSpeed-Cache-Control.
* Provides helpers for determining cacheability, emitting cache-control headers,
* and honoring various LiteSpeed Cache configuration options.
defined( 'WPINC' ) || exit();
* Handles cache-control flags, TTL calculation, redirection checks,
* role-based exclusions, and final header output.
class Control extends Root {
const BM_FORCED_CACHEABLE = 32;
const BM_PUBLIC_FORCED = 64;
const BM_NOTCACHEABLE = 256;
const X_HEADER = 'X-LiteSpeed-Cache-Control';
* Bitmask control flags for current request.
protected static $_control = 0;
* Custom TTL for current request (seconds).
protected static $_custom_ttl = 0;
* Mapping of HTTP status codes to custom TTLs.
* @var array<string,int|string>
private $_response_header_ttls = [];
* Add vary filter for Role Excludes.
add_filter( 'litespeed_vary', [ $this, 'vary_add_role_exclude' ] );
add_filter( 'wp_redirect', [ $this, 'check_redirect' ], 10, 2 );
// Load response header conf.
$this->_response_header_ttls = $this->conf( Base::O_CACHE_TTL_STATUS );
foreach ( $this->_response_header_ttls as $k => $v ) {
if ( empty( $v[0] ) || empty( $v[1] ) ) {
$this->_response_header_ttls[ $v[0] ] = $v[1];
if ( $this->conf( Base::O_PURGE_STALE ) ) {
* Exclude role from optimization filter.
* @param array<string,mixed> $vary Existing vary map.
* @return array<string,mixed>
public function vary_add_role_exclude( $vary ) {
if ( $this->in_cache_exc_roles() ) {
$vary['role_exclude_cache'] = 1;
* Check if one user role is in exclude cache group settings.
* @since 3.0 Moved here from conf.cls
* @param string|null $role The user role.
* @return string|false Comma-separated roles if set, otherwise false.
public function in_cache_exc_roles( $role = null ) {
$role = Router::get_role();
$roles = explode( ',', $role );
$found = array_intersect( $roles, $this->conf( Base::O_CACHE_EXC_ROLES ) );
return $found ? implode( ',', $found ) : false;
* 1. Initialize cacheable status for `wp` hook
* 2. Hook error page tags for cacheable pages
public function init_cacheable() {
// Hook `wp` to mark default cacheable status.
// NOTE: Any process that does NOT run into `wp` hook will not get cacheable by default.
add_action( 'wp', [ $this, 'set_cacheable' ], 5 );
// Hook WP REST to be cacheable.
if ( $this->conf( Base::O_CACHE_REST ) ) {
add_action( 'rest_api_init', [ $this, 'set_cacheable' ], 5 );
$ajax_cache = $this->conf( Base::O_CACHE_AJAX_TTL );
foreach ( $ajax_cache as $v ) {
if ( empty( $v[0] ) || empty( $v[1] ) ) {
'wp_ajax_nopriv_' . $v[0],
self::set_custom_ttl( $v[1] );
self::force_cacheable( 'ajax Cache setting for action ' . $v[0] );
add_filter( 'status_header', [ $this, 'check_error_codes' ], 10, 2 );
* Check if the page returns any error code.
* @param string $status_header Status header.
* @param int $code HTTP status code.
* @return string Original status header.
public function check_error_codes( $status_header, $code ) {
if ( array_key_exists( $code, $this->_response_header_ttls ) ) {
if ( self::is_cacheable() && ! $this->_response_header_ttls[ $code ] ) {
self::set_nocache( '[Ctrl] TTL is set to no cache [status_header] ' . $code );
self::set_custom_ttl( $this->_response_header_ttls[ $code ] );
} elseif ( self::is_cacheable() ) {
$first = substr( $code, 0, 1 );
if ( '4' === $first || '5' === $first ) {
self::set_nocache( '[Ctrl] 4xx/5xx default to no cache [status_header] ' . $code );
if ( in_array( $code, Tag::$error_code_tags, true ) ) {
Tag::add( Tag::TYPE_HTTP . $code );
// Give the default status_header back.
public static function set_no_vary() {
if ( self::is_no_vary() ) {
self::$_control |= self::BM_NO_VARY;
self::debug( 'X Cache_control -> no-vary', 3 );
public static function is_no_vary() {
return self::$_control & self::BM_NO_VARY;
public function set_stale() {
if ( self::is_stale() ) {
self::$_control |= self::BM_STALE;
self::debug( 'X Cache_control -> stale' );
public static function is_stale() {
return self::$_control & self::BM_STALE;
* Set cache control to shared private.
* @param string|false $reason The reason to mark shared, or false.
public static function set_shared( $reason = false ) {
if ( self::is_shared() ) {
self::$_control |= self::BM_SHARED;
if ( ! is_string( $reason ) ) {
self::debug( 'X Cache_control -> shared ' . $reason );
* Check if is shared private.
public static function is_shared() {
return (bool) ( self::$_control & self::BM_SHARED ) && self::is_private();
* Set cache control to forced public.
* @param string|false $reason Reason text or false.
public static function set_public_forced( $reason = false ) {
if ( self::is_public_forced() ) {
self::$_control |= self::BM_PUBLIC_FORCED;
if ( ! is_string( $reason ) ) {
self::debug( 'X Cache_control -> public forced ' . $reason );
* Check if is public forced.
public static function is_public_forced() {
return self::$_control & self::BM_PUBLIC_FORCED;
* Set cache control to private.
* @param string|false $reason The reason to set private.
public static function set_private( $reason = false ) {
if ( self::is_private() ) {
self::$_control |= self::BM_PRIVATE;
if ( ! is_string( $reason ) ) {
self::debug( 'X Cache_control -> private ' . $reason );
public static function is_private() {
// if ( defined( 'LITESPEED_GUEST' ) && LITESPEED_GUEST ) {
return (bool) ( self::$_control & self::BM_PRIVATE ) && ! self::is_public_forced();
* Initialize cacheable status in `wp` hook, if not call this, by default it will be non-cacheable.
* @param string|false $reason Reason text or false.
public function set_cacheable( $reason = false ) {
self::$_control |= self::BM_CACHEABLE;
if ( ! is_string( $reason ) ) {
$reason = ' [reason] ' . $reason;
self::debug( 'Cache_control init on' . $reason );
* This will disable non-cacheable BM.
* @param string|false $reason Reason text or false.
public static function force_cacheable( $reason = false ) {
self::$_control |= self::BM_FORCED_CACHEABLE;
if ( ! is_string( $reason ) ) {
$reason = ' [reason] ' . $reason;
self::debug( 'Forced cacheable' . $reason );
* Switch to nocacheable status.
* @param string|false $reason The reason to no cache.
public static function set_nocache( $reason = false ) {
self::$_control |= self::BM_NOTCACHEABLE;
if ( ! is_string( $reason ) ) {
self::debug( 'X Cache_control -> no Cache ' . $reason, 5 );
* Check current notcacheable bit set.
* @return bool True if notcacheable bit is set, otherwise false.
public static function isset_notcacheable() {
return self::$_control & self::BM_NOTCACHEABLE;
* Check current force cacheable bit set.
public static function is_forced_cacheable() {
return self::$_control & self::BM_FORCED_CACHEABLE;
* Check current cacheable status.
* @return bool True if is still cacheable, otherwise false.
public static function is_cacheable() {
if ( defined( 'LSCACHE_NO_CACHE' ) && LSCACHE_NO_CACHE ) {
self::debug( 'LSCACHE_NO_CACHE constant defined' );
// Guest mode always cacheable
// if ( defined( 'LITESPEED_GUEST' ) && LITESPEED_GUEST ) {
// If it's forced public cacheable.
if ( self::is_public_forced() ) {
// If it's forced cacheable.
if ( self::is_forced_cacheable() ) {
return ! self::isset_notcacheable() && ( self::$_control & self::BM_CACHEABLE );
* Set a custom TTL to use with the request if needed.
* @param int|string $ttl An integer or numeric string to use as the TTL.
* @param string|false $reason Optional reason text.
public static function set_custom_ttl( $ttl, $reason = false ) {