return array_filter( $results );
* Generate browser cache rules.
* @param array<string,mixed> $cfg The plugin configuration.
* @return array<int,string> Rules set.
private function _browser_cache_rules( $cfg ) {
$id = Base::O_CACHE_TTL_BROWSER;
self::EXPIRES_MODULE_START,
'ExpiresByType application/pdf A' . $ttl,
'ExpiresByType image/x-icon A' . $ttl,
'ExpiresByType image/vnd.microsoft.icon A' . $ttl,
'ExpiresByType image/svg+xml A' . $ttl,
'ExpiresByType image/jpg A' . $ttl,
'ExpiresByType image/jpeg A' . $ttl,
'ExpiresByType image/png A' . $ttl,
'ExpiresByType image/gif A' . $ttl,
'ExpiresByType image/webp A' . $ttl,
'ExpiresByType image/avif A' . $ttl,
'ExpiresByType video/ogg A' . $ttl,
'ExpiresByType audio/ogg A' . $ttl,
'ExpiresByType video/mp4 A' . $ttl,
'ExpiresByType video/webm A' . $ttl,
'ExpiresByType text/css A' . $ttl,
'ExpiresByType text/javascript A' . $ttl,
'ExpiresByType application/javascript A' . $ttl,
'ExpiresByType application/x-javascript A' . $ttl,
'ExpiresByType application/x-font-ttf A' . $ttl,
'ExpiresByType application/x-font-woff A' . $ttl,
'ExpiresByType application/font-woff A' . $ttl,
'ExpiresByType application/font-woff2 A' . $ttl,
'ExpiresByType application/vnd.ms-fontobject A' . $ttl,
'ExpiresByType font/ttf A' . $ttl,
'ExpiresByType font/otf A' . $ttl,
'ExpiresByType font/woff A' . $ttl,
'ExpiresByType font/woff2 A' . $ttl,
* Generate CORS rules for fonts.
* @return array<int,string> Rules set.
private function _cors_rules() {
'<FilesMatch "\.(ttf|ttc|otf|eot|woff|woff2|font\.css)$">',
'<IfModule mod_headers.c>',
'Header set Access-Control-Allow-Origin "*"',
* Generate rewrite rules based on settings.
* @param array<string,mixed> $cfg The settings to be used for rewrite rule.
* @return array{0:array<int,string>,1:array<int,string>,2:array<int,string>,3:array<int,string>} Rules arrays [frontend_ls, backend_ls, frontend_nonls, backend_nonls].
private function _generate_rules( $cfg ) {
$new_rules_nonls = array();
$new_rules_backend = array();
$new_rules_backend_nonls = array();
$new_rules[] = self::MARKER_ASYNC . self::MARKER_START;
$new_rules[] = 'RewriteCond %{REQUEST_URI} /wp-admin/admin-ajax\.php';
$new_rules[] = 'RewriteCond %{QUERY_STRING} action=async_litespeed';
$new_rules[] = 'RewriteRule .* - [E=noabort:1]';
$new_rules[] = self::MARKER_ASYNC . self::MARKER_END;
$id = Base::O_CACHE_MOBILE_RULES;
if ( ( ! empty( $cfg[ Base::O_CACHE_MOBILE ] ) || ! empty( $cfg[ Base::O_GUEST ] ) ) && ! empty( $cfg[ $id ] ) ) {
$new_rules[] = self::MARKER_MOBILE . self::MARKER_START;
$new_rules[] = 'RewriteCond %{HTTP_USER_AGENT} ' . Utility::arr2regex( $cfg[ $id ], true ) . ' [NC]';
$new_rules[] = 'RewriteRule .* - [E=Cache-Control:vary=%{ENV:LSCACHE_VARY_VALUE}+ismobile]';
$new_rules[] = self::MARKER_MOBILE . self::MARKER_END;
$id = Base::O_CACHE_EXC_COOKIES;
if ( ! empty( $cfg[ $id ] ) ) {
$new_rules[] = self::MARKER_NOCACHE_COOKIES . self::MARKER_START;
$new_rules[] = 'RewriteCond %{HTTP_COOKIE} ' . Utility::arr2regex( $cfg[ $id ], true );
$new_rules[] = 'RewriteRule .* - [E=Cache-Control:no-cache]';
$new_rules[] = self::MARKER_NOCACHE_COOKIES . self::MARKER_END;
$id = Base::O_CACHE_EXC_USERAGENTS;
if ( ! empty( $cfg[ $id ] ) ) {
$new_rules[] = self::MARKER_NOCACHE_USER_AGENTS . self::MARKER_START;
$new_rules[] = 'RewriteCond %{HTTP_USER_AGENT} ' . Utility::arr2regex( $cfg[ $id ], true ) . ' [NC]';
$new_rules[] = 'RewriteRule .* - [E=Cache-Control:no-cache]';
$new_rules[] = self::MARKER_NOCACHE_USER_AGENTS . self::MARKER_END;
$vary_cookies = $cfg[ Base::O_CACHE_VARY_COOKIES ];
$id = Base::O_CACHE_LOGIN_COOKIE;
if ( ! empty( $cfg[ $id ] ) ) {
$vary_cookies[] = $cfg[ $id ];
if ( LITESPEED_SERVER_TYPE === 'LITESPEED_SERVER_OLS' ) {
// Need to keep this due to different behavior of OLS when handling response vary header @Sep/22/2018.
if ( defined( 'COOKIEHASH' ) ) {
$vary_cookies[] = ',wp-postpass_' . COOKIEHASH;
$vary_cookies = apply_filters( 'litespeed_vary_cookies', $vary_cookies ); // todo: test if response vary header can work in latest OLS, drop the above two lines.
$env = 'Cache-Vary:' . implode( ',', $vary_cookies );
$new_rules[] = self::MARKER_LOGIN_COOKIE . self::MARKER_START;
$new_rules_backend[] = self::MARKER_LOGIN_COOKIE . self::MARKER_START;
$new_rules[] = 'RewriteRule .? - [E=' . $env . ']';
$new_rules_backend[] = 'RewriteRule .? - [E=' . $env . ']';
$new_rules[] = self::MARKER_LOGIN_COOKIE . self::MARKER_END;
$new_rules_backend[] = self::MARKER_LOGIN_COOKIE . self::MARKER_END;
if ( ! empty( $cfg[ $id ] ) ) {
$new_rules[] = self::MARKER_CORS . self::MARKER_START;
$new_rules = array_merge( $new_rules, $this->_cors_rules() ); // todo: network.
$new_rules[] = self::MARKER_CORS . self::MARKER_END;
// webp/next-gen support.
$id = Base::O_IMG_OPTM_WEBP;
if ( ! empty( $cfg[ $id ] ) ) {
$next_gen_format = 'webp';
if ( 2 === (int) $cfg[ $id ] ) {
$next_gen_format = 'avif';
$new_rules[] = self::MARKER_WEBP . self::MARKER_START;
// Check for WebP/AVIF support via HTTP_ACCEPT.
$new_rules[] = 'RewriteCond %{HTTP_ACCEPT} image/' . $next_gen_format . ' [OR]';
// Check for iPhone browsers (version > 13).
$new_rules[] = 'RewriteCond %{HTTP_USER_AGENT} iPhone\ OS\ (1[4-9]|[2-9][0-9]) [OR]';
// Check for macOS Safari (version >= 16.4).
$new_rules[] = 'RewriteCond %{HTTP_USER_AGENT} Macintosh.*Version/((1[7-9]|[2-9][0-9])|16\.([4-9]|[1-9][0-9])) [OR]';
// Check for Firefox (version >= 65).
$new_rules[] = 'RewriteCond %{HTTP_USER_AGENT} Firefox/([6-9][0-9]|[1-9][0-9]{2,})';
$new_rules[] = 'RewriteRule .* - [E=Cache-Control:vary=%{ENV:LSCACHE_VARY_VALUE}+webp]';
$new_rules[] = self::MARKER_WEBP . self::MARKER_END;
$id = Base::O_CACHE_DROP_QS;
if ( ! empty( $cfg[ $id ] ) ) {
$new_rules[] = self::MARKER_DROPQS . self::MARKER_START;
foreach ( $cfg[ $id ] as $v ) {
$new_rules[] = 'CacheKeyModify -qs:' . $v;
$new_rules[] = self::MARKER_DROPQS . self::MARKER_END;
$id = Base::O_CACHE_BROWSER;
if ( ! empty( $cfg[ $id ] ) ) {
$new_rules_nonls[] = self::MARKER_BROWSER_CACHE . self::MARKER_START;
$new_rules_nonls = array_merge( $new_rules_nonls, $this->_browser_cache_rules( $cfg ) );
$new_rules_nonls[] = self::MARKER_BROWSER_CACHE . self::MARKER_END;
$new_rules_backend_nonls[] = self::MARKER_BROWSER_CACHE . self::MARKER_START;
$new_rules_backend_nonls = array_merge( $new_rules_backend_nonls, $this->_browser_cache_rules( $cfg ) );
$new_rules_backend_nonls[] = self::MARKER_BROWSER_CACHE . self::MARKER_END;
$new_rules_backend_nonls[] = '';
// Add module wrapper for LiteSpeed rules.
$new_rules = $this->_wrap_ls_module( $new_rules );
if ( $new_rules_backend ) {
$new_rules_backend = $this->_wrap_ls_module( $new_rules_backend );
return array( $new_rules, $new_rules_backend, $new_rules_nonls, $new_rules_backend_nonls );
* Add LiteSpeed module wrapper with rewrite on.
* @param array<int,string> $rules Rules to wrap.
* @return array<int,string> Wrapped rules.
private function _wrap_ls_module( $rules = array() ) {
return array_merge( $this->__rewrite_general, array( self::LS_MODULE_START ), $this->__rewrite_on, array( '' ), $rules, array( self::LS_MODULE_END )
* Insert LiteSpeed module wrapper with rewrite on.
public function insert_ls_wrapper() {
$rules = $this->_wrap_ls_module();
$this->_insert_wrapper( $rules );
* Wrap rules with do-not-edit markers.
* @param array<int,string>|false $rules Rules array or false.
* @return array<int,string>|false Wrapped rules, or false if $rules was false.
private function _wrap_do_no_edit( $rules ) {
// When clearing rules, don't need DO NOT EDIT msg.
if ( false === $rules || ! is_array( $rules ) ) {
$rules = array_merge( array( self::LS_MODULE_DONOTEDIT ), $rules, array( self::LS_MODULE_DONOTEDIT ) );
* Write to htaccess with rules.
* NOTE: will throw error if failed.
* @param array<int,string>|false $rules Rules to write. Pass false to clear.
* @param string|false $kind 'frontend' or 'backend'. Defaults to 'frontend'.
* @param string|false $marker Marker name. Defaults to self::MARKER.
* @throws \Exception If write fails.
private function _insert_wrapper( $rules = array(), $kind = false, $marker = false ) {
if ( 'backend' !== $kind ) {
// Default marker is LiteSpeed marker `LSCACHE`.
if ( false === $marker ) {
$this->_htaccess_backup( $kind );
File::insert_with_markers( $this->htaccess_path( $kind ), $this->_wrap_do_no_edit( $rules ), $marker, true );
* Update rewrite rules based on setting.
* NOTE: will throw error if failed.
* @param array<string,mixed> $cfg Plugin configuration.
* @return bool True on success.
* @throws \Exception When automatic update fails (provides manual instructions).
public function update( $cfg ) {
list( $frontend_rules, $backend_rules, $frontend_rules_nonls, $backend_rules_nonls ) = $this->_generate_rules( $cfg );
// Check frontend content.
list( $rules, $rules_nonls ) = $this->_extract_rules();
// Check Non-LiteSpeed rules.
if ( $this->_wrap_do_no_edit( $frontend_rules_nonls ) !== $rules_nonls ) {
Debug2::debug( '[Rules] Update non-ls frontend rules' );
// Need to update frontend htaccess.
$this->_insert_wrapper( $frontend_rules_nonls, false, self::MARKER_NONLS );
} catch ( \Exception $e ) {
$manual_guide_codes = $this->_rewrite_codes_msg( $this->frontend_htaccess, $frontend_rules_nonls, self::MARKER_NONLS );
Debug2::debug( '[Rules] Update Failed' );
throw new \Exception( $manual_guide_codes ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Message is for admin display.
// Check LiteSpeed rules.
if ( $this->_wrap_do_no_edit( $frontend_rules ) !== $rules ) {
Debug2::debug( '[Rules] Update frontend rules' );
// Need to update frontend htaccess.
$this->_insert_wrapper( $frontend_rules );
} catch ( \Exception $e ) {
Debug2::debug( '[Rules] Update Failed' );
$manual_guide_codes = $this->_rewrite_codes_msg( $this->frontend_htaccess, $frontend_rules );
throw new \Exception( $manual_guide_codes ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Message is for admin display.
if ( $this->frontend_htaccess !== $this->backend_htaccess ) {
list( $rules, $rules_nonls ) = $this->_extract_rules( 'backend' );
// Check Non-LiteSpeed rules for backend.
if ( $this->_wrap_do_no_edit( $backend_rules_nonls ) !== $rules_nonls ) {
Debug2::debug( '[Rules] Update non-ls backend rules' );
// Need to update backend htaccess.
$this->_insert_wrapper( $backend_rules_nonls, 'backend', self::MARKER_NONLS );
} catch ( \Exception $e ) {
Debug2::debug( '[Rules] Update Failed' );
$manual_guide_codes = $this->_rewrite_codes_msg( $this->backend_htaccess, $backend_rules_nonls, self::MARKER_NONLS );
throw new \Exception( $manual_guide_codes ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Message is for admin display.
// Check backend content.
if ( $this->_wrap_do_no_edit( $backend_rules ) !== $rules ) {
Debug2::debug( '[Rules] Update backend rules' );
// Need to update backend htaccess.
$this->_insert_wrapper( $backend_rules, 'backend' );
} catch ( \Exception $e ) {
Debug2::debug( '[Rules] Update Failed' );
$manual_guide_codes = $this->_rewrite_codes_msg( $this->backend_htaccess, $backend_rules );
throw new \Exception( $manual_guide_codes ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Message is for admin display.
* Get existing rewrite rules.
* NOTE: will throw error if failed.
* @param string $kind Frontend or backend .htaccess file.
* @return array{0:array<int,string>,1:array<int,string>} A tuple of [ls_rules, nonls_rules].
* @throws \Exception If file is not readable.
private function _extract_rules( $kind = 'frontend' ) {
$path = $this->htaccess_path( $kind );
if ( ! $this->_readable( $kind ) ) {
$rules = File::extract_from_markers( $path, self::MARKER );
$rules_nonls = File::extract_from_markers( $path, self::MARKER_NONLS );
return array( $rules, $rules_nonls );
* Output the msg with rules plain data for manual insert.
* @param string $file The target file path.
* @param array<int,string> $rules The rules to be inserted.
* @param string|false $marker Optional marker name. Defaults to LiteSpeed's marker.
* @return string The final message (HTML) to output.
private function _rewrite_codes_msg( $file, $rules, $marker = false ) {
/* translators: 1: file path, 2: code block */
__( '<p>Please add/replace the following codes into the beginning of %1$s:</p> %2$s', 'litespeed-cache' ),
'<textarea style="width:100%;" rows="10" readonly>' . esc_textarea( $this->_wrap_rules_with_marker( $rules, $marker ) ) . '</textarea>'
* Generate rules plain data for manual insert.
* @param array<int,string>|false $rules Rules to wrap or false.
* @param string|false $marker Optional marker name. Defaults to LiteSpeed's marker.
* @return string The plain text of the rules with markers.
private function _wrap_rules_with_marker( $rules, $marker = false ) {
// Default marker is LiteSpeed marker `LSCACHE`.
if ( false === $marker ) {
$start_marker = "# BEGIN {$marker}";
$end_marker = "# END {$marker}";
$new_file_data = implode( "\n", array_merge( array( $start_marker ), $this->_wrap_do_no_edit( $rules ), array( $end_marker ) ) );
* Clear the rules file of any changes added by the plugin specifically.
public function clear_rules() {
$this->_insert_wrapper( false ); // Use false to avoid do-not-edit msg.
$this->_insert_wrapper( false, false, self::MARKER_NONLS );
if ( $this->frontend_htaccess !== $this->backend_htaccess ) {
$this->_insert_wrapper( false, 'backend' );
$this->_insert_wrapper( false, 'backend', self::MARKER_NONLS );