if ( is_numeric( $ttl ) ) {
self::$_custom_ttl = (int) $ttl;
self::debug( 'X Cache_control TTL -> ' . $ttl . ( $reason ? ' [reason] ' . $ttl : '' ) );
public function get_ttl() {
if ( 0 !== self::$_custom_ttl ) {
return (int) self::$_custom_ttl;
// Check if is in timed url list or not.
$timed_urls = Utility::wildcard2regex( $this->conf( Base::O_PURGE_TIMED_URLS ) );
$timed_urls_time = $this->conf( Base::O_PURGE_TIMED_URLS_TIME );
if ( $timed_urls && $timed_urls_time ) {
$current_url = Tag::build_uri_tag( true );
$scheduled_time = strtotime( $timed_urls_time );
$ttl = $scheduled_time - current_time('timestamp'); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp
$ttl += 86400; // add one day
foreach ( $timed_urls as $v ) {
if ( false !== strpos( $v, '*' ) ) {
if ( preg_match( '#' . $v . '#iU', $current_url ) ) {
self::debug( 'X Cache_control TTL is limited to ' . $ttl . ' due to scheduled purge regex ' . $v );
} elseif ( $v === $current_url ) {
self::debug( 'X Cache_control TTL is limited to ' . $ttl . ' due to scheduled purge rule ' . $v );
// Private cache uses private ttl setting.
if ( self::is_private() ) {
return (int) $this->conf( Base::O_CACHE_TTL_PRIV );
return (int) $this->conf( Base::O_CACHE_TTL_FRONTPAGE );
$feed_ttl = (int) $this->conf( Base::O_CACHE_TTL_FEED );
if ( is_feed() && $feed_ttl > 0 ) {
if ( $this->cls( 'REST' )->is_rest() || $this->cls( 'REST' )->is_internal_rest() ) {
return (int) $this->conf( Base::O_CACHE_TTL_REST );
return (int) $this->conf( Base::O_CACHE_TTL_PUB );
* Check if need to set no cache status for redirection or not.
* @param string $location Redirect location.
* @param int $status HTTP status.
* @return string Redirect location.
public function check_redirect( $location, $status ) {
if ( !empty( $_SERVER['SCRIPT_URI'] ) ) {
$script_uri = sanitize_text_field( wp_unslash( $_SERVER['SCRIPT_URI'] ) );
} elseif ( !empty( $_SERVER['REQUEST_URI'] ) ) {
$home = trailingslashit( home_url() );
$script_uri = $home . ltrim( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), '/' );
if ( '' !== $script_uri ) {
self::debug( '301 from ' . $script_uri );
self::debug( '301 to ' . $location );
$to_check = [ PHP_URL_SCHEME, PHP_URL_HOST, PHP_URL_PATH, PHP_URL_QUERY ];
$is_same_redirect = true;
$query_string = ! empty( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : '';
foreach ( $to_check as $v ) {
$url_parsed = PHP_URL_QUERY === $v ? $query_string : wp_parse_url( $script_uri, $v );
$target = wp_parse_url( $location, $v );
self::debug( 'Compare [from] ' . $url_parsed . ' [to] ' . $target );
if ( PHP_URL_QUERY === $v ) {
$url_parsed = $url_parsed ? urldecode( $url_parsed ) : '';
$target = $target ? urldecode( $target ) : '';
if ( '&' === substr( $url_parsed, -1 ) ) {
$url_parsed = substr( $url_parsed, 0, -1 );
if ( $url_parsed !== $target ) {
$is_same_redirect = false;
self::debug( '301 different redirection' );
if ( $is_same_redirect ) {
self::set_nocache( '301 to same url' );
* Sets up the Cache Control header.
* @return string empty string if empty, otherwise the cache control header.
public function output() {
$hdr = self::X_HEADER . ': ';
// phpcs:ignore WordPress.NamingConventions.ValidHookName.NotLowercase
if ( defined( 'DONOTCACHEPAGE' ) && apply_filters( 'litespeed_const_DONOTCACHEPAGE', DONOTCACHEPAGE ) ) {
self::debug( '❌ forced no cache [reason] DONOTCACHEPAGE const' );
$hdr .= 'no-cache' . $esi_hdr;
// Guest mode directly return cacheable result
// if ( defined( 'LITESPEED_GUEST' ) && LITESPEED_GUEST ) {
// if ( defined( 'LSCACHE_NO_CACHE' ) && LSCACHE_NO_CACHE ) {
// self::debug( "[Ctrl] ❌ forced no cache [reason] LSCACHE_NO_CACHE const" );
// else if( $_SERVER[ 'REQUEST_METHOD' ] !== 'GET' ) {
// self::debug( "[Ctrl] ❌ forced no cache [reason] req not GET" );
// $hdr .= ',max-age=' . $this->get_ttl();
// Fix cli `uninstall --deactivate` fatal err
if (!self::is_cacheable()) {
$hdr .= 'no-cache' . $esi_hdr;
if ( self::is_shared() ) {
$hdr .= 'shared,private';
} elseif ( self::is_private() ) {
if ( self::is_no_vary() ) {
$hdr .= ',max-age=' . $this->get_ttl() . $esi_hdr;
* Generate all `control` tags before output.
public function finalize() {
// if ( defined( 'LITESPEED_GUEST' ) && LITESPEED_GUEST ) {
self::set_nocache( 'preview page' );
// Check if has metabox non-cacheable setting or not.
if ( file_exists( LSCWP_DIR . 'src/metabox.cls.php' ) && $this->cls( 'Metabox' )->setting( 'litespeed_no_cache' ) ) {
self::set_nocache( 'per post metabox setting' );
// Check if URI is forced public cache.
$excludes = $this->conf( Base::O_CACHE_FORCE_PUB_URI );
$req_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
$hit = Utility::str_hit_array( $req_uri, $excludes, true );
list( $result, $this_ttl ) = $hit;
self::set_public_forced( 'Setting: ' . $result );
self::debug( 'Forced public cacheable due to setting: ' . $result );
self::set_custom_ttl( $this_ttl );
if ( self::is_public_forced() ) {
// Check if URI is forced cache.
$excludes = $this->conf( Base::O_CACHE_FORCE_URI );
$hit = Utility::str_hit_array( $req_uri, $excludes, true );
list( $result, $this_ttl ) = $hit;
self::debug( 'Forced cacheable due to setting: ' . $result );
self::set_custom_ttl( $this_ttl );
// if is not cacheable, terminate check.
// Even no need to run 3rd party hook.
if ( ! self::is_cacheable() ) {
self::debug( 'not cacheable before ctrl finalize' );
// Apply 3rd party filter.
// NOTE: Hook always needs to run asap because some 3rd party set is_mobile in this hook.
do_action( 'litespeed_control_finalize', defined( 'LSCACHE_IS_ESI' ) ? LSCACHE_IS_ESI : false ); // Pass ESI block id.
// if is not cacheable, terminate check.
if ( ! self::is_cacheable() ) {
self::debug( 'not cacheable after api_control' );
// Check litespeed setting to set cacheable status.
if ( ! $this->_setting_cacheable() ) {
// If user has password cookie, do not cache (moved from vary).
if ( ! empty( $post->post_password ) && isset( $_COOKIE[ 'wp-postpass_' . COOKIEHASH ] ) ) {
self::set_nocache( 'pswd cookie' );
// The following check to the end is ONLY for mobile.
$is_mobile_conf = apply_filters( 'litespeed_is_mobile', false );
if ( ! $this->conf( Base::O_CACHE_MOBILE ) ) {
self::set_nocache( 'mobile' );
$env_vary = isset( $_SERVER['LSCACHE_VARY_VALUE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['LSCACHE_VARY_VALUE'] ) ) : '';
if ( !$env_vary && isset( $_SERVER['HTTP_X_LSCACHE_VARY_VALUE'] ) ) {
$env_vary = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_LSCACHE_VARY_VALUE'] ) );
if ( $env_vary && false !== strpos( $env_vary, 'ismobile' ) ) {
if ( ! wp_is_mobile() && ! $is_mobile_conf ) {
self::set_nocache( 'is not mobile' ); // todo: no need to uncache, it will correct vary value in vary finalize anyways.
} elseif ( wp_is_mobile() || $is_mobile_conf ) {
self::set_nocache( 'is mobile' );
* Check if is mobile for filter `litespeed_is_mobile` in API.
public static function is_mobile() {
* Get request method w/ compatibility to X-Http-Method-Override.
private function _get_req_method() {
if ( isset( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ) ) {
$override = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ) );
self::debug( 'X-Http-Method-Override -> ' . $override );
if ( ! defined( 'LITESPEED_X_HTTP_METHOD_OVERRIDE' ) ) {
define( 'LITESPEED_X_HTTP_METHOD_OVERRIDE', true );
if ( isset( $_SERVER['REQUEST_METHOD'] ) ) {
return sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) );
* Check if a page is cacheable based on litespeed setting.
* @return bool True if cacheable, false otherwise.
private function _setting_cacheable() {
// logged_in users already excluded, no hook added.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! empty( $_REQUEST[ Router::ACTION ] ) ) {
return $this->_no_cache_for( 'Query String Action' );
$method = $this->_get_req_method();
if ( defined( 'LITESPEED_X_HTTP_METHOD_OVERRIDE' ) && LITESPEED_X_HTTP_METHOD_OVERRIDE && 'HEAD' === $method ) {
return $this->_no_cache_for( 'HEAD method from override' );
if ( 'GET' !== $method && 'HEAD' !== $method ) {
return $this->_no_cache_for( 'Not GET method: ' . $method );
if ( is_feed() && 0 === $this->conf( Base::O_CACHE_TTL_FEED ) ) {
return $this->_no_cache_for( 'feed' );
return $this->_no_cache_for( 'trackback' );
return $this->_no_cache_for( 'search' );
// Check private cache URI setting.
$excludes = $this->conf( Base::O_CACHE_PRIV_URI );
$req_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
$result = Utility::str_hit_array( $req_uri, $excludes );
self::set_private( 'Admin cfg Private Cached URI: ' . $result );
if ( ! self::is_forced_cacheable() ) {
// Check if URI is excluded from cache.
$excludes = $this->cls( 'Data' )->load_cache_nocacheable( $this->conf( Base::O_CACHE_EXC ) );
$result = Utility::str_hit_array( $req_uri, $excludes );
return $this->_no_cache_for( 'Admin configured URI Do not cache: ' . $result );
// Check QS excluded setting.
$excludes = $this->conf( Base::O_CACHE_EXC_QS );
$qs_hit = $this->_is_qs_excluded( $excludes );
if ( ! empty( $excludes ) && $qs_hit ) {
return $this->_no_cache_for( 'Admin configured QS Do not cache: ' . $qs_hit );
$excludes = $this->conf( Base::O_CACHE_EXC_CAT );
if ( ! empty( $excludes ) && has_category( $excludes ) ) {
return $this->_no_cache_for( 'Admin configured Category Do not cache.' );
$excludes = $this->conf( Base::O_CACHE_EXC_TAG );
if ( ! empty( $excludes ) && has_tag( $excludes ) ) {
return $this->_no_cache_for( 'Admin configured Tag Do not cache.' );
$excludes = $this->conf( Base::O_CACHE_EXC_COOKIES );
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- names only, compared as keys.
if ( ! empty( $excludes ) && ! empty( $_COOKIE ) ) {
$cookie_hit = array_intersect( array_keys( $_COOKIE ), $excludes );
return $this->_no_cache_for( 'Admin configured Cookie Do not cache.' );
$excludes = $this->conf( Base::O_CACHE_EXC_USERAGENTS );
if ( ! empty( $excludes ) && isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
$nummatches = preg_match( Utility::arr2regex( $excludes ), sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) );
return $this->_no_cache_for( 'Admin configured User Agent Do not cache.' );
// Check if is exclude roles ( Need to set Vary too ).
$result = $this->in_cache_exc_roles();
return $this->_no_cache_for( 'Role Excludes setting ' . $result );
* Write a debug message for if a page is not cacheable.
* @param string $reason An explanation for why the page is not cacheable.
* @return bool Always false.
private function _no_cache_for( $reason ) {
self::debug( 'X Cache_control off - ' . $reason );
* Check if current request has qs excluded setting.
* @param array<int,string> $excludes QS excludes setting.
* @return bool|string False if not excluded, otherwise the hit qs list.
private function _is_qs_excluded( $excludes ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! empty( $_GET ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$keys = array_keys( $_GET );
$intersect = array_intersect( $keys, $excludes );
return implode( ',', $intersect );