is_debug_mode = $this->is_debug_enabled();
add_action( 'init', array( $this, 'init' ) );
register_activation_hook( FASTLAYER_FILE, array( $this, 'activate' ) );
register_deactivation_hook( FASTLAYER_FILE, array( $this, 'deactivate' ) );
add_action( 'update_option_fastlayer_options', array( $this, 'sync_static_config' ), 10, 3 );
add_action( 'add_option_fastlayer_options', array( $this, 'sync_static_config' ), 10, 2 );
// On multisite each site keeps its own config file. A site created after
// activation must be added to the network index at creation time, or the
// drop-in cannot resolve it until its first front-end request (and until
// then the main site's entry would answer for it).
add_action( 'wp_initialize_site', array( $this, 'on_initialize_site' ), 20, 1 );
// The drop-in resolves a site by the home URL recorded in sites.json, so
// a home URL change (Settings -> General, network admin, or a
// domain-mapping plugin) must refresh the index immediately. Without
// this the site stays unresolvable to the drop-in (cache disabled) and
// the old home URL keeps answering for it until its settings are saved.
add_action( 'update_option_home', array( $this, 'on_home_option_updated' ), 10, 3 );
add_action( 'update_option_siteurl', array( $this, 'on_home_option_updated' ), 10, 3 );
// Installs upgraded from the original wp_fastlayer_options naming keep
// writing the legacy row until a settings save flips the plugins to
// the new key. Mirror those writes so the two rows never diverge.
add_action( 'update_option_wp_fastlayer_options', array( $this, 'mirror_legacy_options' ), 10, 3 );
add_action( 'add_option_wp_fastlayer_options', array( $this, 'mirror_legacy_options' ), 10, 2 );
if ( is_admin() ) {
add_action( 'admin_notices', array( $this, 'admin_notices' ) );
}
}
/**
* Records a file FastLayer could not write.
*
* Activation, deactivation and uninstall all run outside the admin screens,
* so the message is stored in a short-lived transient and rendered by
* admin_notices() on the next admin page load. The debug log is written as
* well, but it only reaches the log when WP_DEBUG is enabled, so it is not
* sufficient on its own.
*
* @param string $context File or area the failure belongs to, e.g. '.htaccess'.
* @param string $message Human readable reason.
*/
public static function record_write_failure( $context, $message ) {
\fastlayer_debug_log( 'WP FastLayer: ' . $context . ' write failure: ' . $message );
$failures = get_transient( self::WRITE_FAILURE_TRANSIENT );
$failures = is_array( $failures ) ? $failures : array();
$failures[] = $context . ': ' . $message;
// Keep only the most recent entries so a repeatedly failing task cannot
// grow the option row without bound.
set_transient( self::WRITE_FAILURE_TRANSIENT, array_slice( $failures, -10 ), 300 );
}
public function admin_notices() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$failures = get_transient( self::WRITE_FAILURE_TRANSIENT );
if ( is_array( $failures ) && $failures ) {
echo '
FastLayer: The following files could not be updated:
';
foreach ( $failures as $failure ) {
echo '- ' . esc_html( $failure ) . '
';
}
echo '
';
delete_transient( self::WRITE_FAILURE_TRANSIENT );
}
if ( ! $this->is_cache_directory_writable() ) {
echo 'FastLayer: Cache directory is not writable. Please check permissions for: ' . esc_html( FASTLAYER_CACHE_DIR ) . '
';
}
if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
$dropin_installed = file_exists( self::ADVANCED_CACHE_DROPIN );
if ( $dropin_installed && self::is_our_advanced_cache_dropin( self::ADVANCED_CACHE_DROPIN ) ) {
self::add_wp_cache_constant();
}
if ( $dropin_installed ) {
$config_file = ABSPATH . 'wp-config.php';
$config_content = self::read_wp_config( $config_file );
if ( false === $config_content || ! preg_match( '/define\s*\(\s*[\'"]WP_CACHE[\'"]\s*,/', $config_content ) ) {
echo 'FastLayer: The page cache drop-in is installed but WP_CACHE is not set to true in wp-config.php. Add the following line above the "stop editing" comment: define( \'WP_CACHE\', true );
';
}
}
}
}
private function is_cache_directory_writable() {
$cache_dir = FASTLAYER_CACHE_DIR;
if ( ! file_exists( $cache_dir ) ) {
return wp_mkdir_p( $cache_dir );
}
return wp_is_writable( $cache_dir );
}
private function is_debug_enabled() {
$options = get_option( 'fastlayer_options', array() );
return isset( $options['debug_mode'] ) && (string) $options['debug_mode'] === '1';
}
private function load_modules() {
$modules = array(
'Page_Cache',
'Optimizer',
'Database_Optimization',
'Admin_Bar',
'Webp',
'CDN',
);
foreach ( $modules as $module ) {
$fqcn = 'FastLayer\\' . $module;
if ( class_exists( $fqcn ) ) {
try {
$fqcn::get_instance();
} catch ( \Throwable $e ) {
\fastlayer_debug_log( 'WP FastLayer: failed to load module ' . $module . ' - ' . $e->getMessage() );
}
}
}
if ( ! class_exists( 'FastLayer\\Admin' ) ) {
$admin_file = FASTLAYER_PATH . 'includes/class-admin.php';
if ( file_exists( $admin_file ) ) {
require_once $admin_file;
\fastlayer_debug_log( 'WP FastLayer: required class-admin.php as fallback.' );
}
}
if ( class_exists( 'FastLayer\\Admin' ) ) {
\fastlayer_debug_log( 'WP FastLayer: Admin class exists before instantiation.' );
try {
Admin::get_instance();
\fastlayer_debug_log( 'WP FastLayer: Admin class instantiated successfully.' );
} catch ( \Throwable $e ) {
\fastlayer_debug_log( 'WP FastLayer: failed to load Admin - ' . $e->getMessage() );
}
} else {
\fastlayer_debug_log( 'WP FastLayer: Admin class does not exist at load_modules.' );
}
}
public function init() {
try {
$this->create_cache_directory();
$this->create_logs_directory();
$this->migrate_legacy_state();
$this->reset_options_if_needed();
$this->ensure_static_config();
$this->load_modules();
} catch ( \Throwable $e ) {
\fastlayer_debug_log( 'WP FastLayer init error: ' . $e->getMessage() );
}
}
/**
* Re-creates the static config when it is missing, invalid, or left in the
* legacy executable form.
*
* This makes the JSON migration self-healing: an install that upgraded but
* has not saved settings yet, a file damaged by a partial write, and a site
* whose home URL changed (the drop-in resolves sites by home URL) all end
* up with a valid file on the next request, so the drop-in never has to
* rely on the legacy file or on a stale index for long.
*/
private function ensure_static_config() {
$cache_dir = wp_normalize_path( FASTLAYER_CACHE_DIR );
$legacy_file = $cache_dir . 'config.php';
if ( self::is_multisite_install() ) {
$blog_id = self::current_site_id();
$site_file = $cache_dir . 'sites/' . $blog_id . '.json';
$index_file = $cache_dir . 'sites.json';
// A missing, empty or malformed index, a site whose home URL
// changed, and a missing or corrupt per-site file all leave the
// drop-in unable to load this site's options. write_static_config()
// repairs all of them: it rewrites the per-site file and, when the
// index is unusable, update_site_index() rebuilds it from the sites
// table so the other sites' entries are preserved.
$index = self::read_json_file( $index_file );
$site_valid = is_array( self::read_json_file( $site_file ) );
if ( ! self::index_maps_site( $index, $blog_id ) || ! $site_valid || file_exists( $legacy_file ) ) {
self::write_static_config( get_option( 'fastlayer_options', array() ) );
}
return;
}
$json_file = $cache_dir . 'config.json';
$json_valid = is_array( self::read_json_file( $json_file ) );
if ( ! $json_valid || file_exists( $legacy_file ) ) {
self::write_static_config( get_option( 'fastlayer_options', array() ) );
}
}
/**
* One-time upgrade path for installs created with the original option
* naming.
*
* The option key, cron hooks and batch-lock option moved from the
* wp_fastlayer_* prefix to the fastlayer_* prefix. Existing, non-empty
* settings are copied across, and a scheduled legacy preload event is
* re-created under its new name instead of being left to fire with no
* handler. Nothing is deleted from the legacy row; the mirror hooks in the
* constructor keep both rows in sync until the next settings save.
*/
private function migrate_legacy_state() {
if ( false === get_option( 'fastlayer_options', false ) ) {
$legacy = get_option( 'wp_fastlayer_options', array() );
if ( is_array( $legacy ) && ! empty( $legacy ) ) {
update_option( 'fastlayer_options', $legacy, false );
}
}
$legacy_cron = wp_next_scheduled( 'wp_fastlayer_preload_cache' );
if ( $legacy_cron ) {
wp_clear_scheduled_hook( 'wp_fastlayer_preload_cache' );
if ( ! wp_next_scheduled( 'fastlayer_preload_cache' ) ) {
wp_schedule_event( $legacy_cron, 'hourly', 'fastlayer_preload_cache' );
}
}
if ( wp_next_scheduled( 'wp_fastlayer_preload_batch' ) ) {
wp_clear_scheduled_hook( 'wp_fastlayer_preload_batch' );
}
}
/**
* Keeps the migrated option row in sync with writes made to the legacy
* wp_fastlayer_options key. Signature mirrors sync_static_config().
*/
public function mirror_legacy_options( $param1, $param2 = null, $param3 = null ) {
$value = is_array( $param3 ) ? $param3 : $param2;
if ( is_array( $value ) && ! empty( $value ) ) {
update_option( 'fastlayer_options', $value, false );
}
}
private function reset_options_if_needed() {
$options = get_option( 'fastlayer_options', array() );
if ( ! is_array( $options ) || empty( $options ) ) {
$this->set_default_options();
return;
}
$default_options = self::get_default_options();
$normalized_options = wp_parse_args( $options, $default_options );
$normalized_options['_version'] = FASTLAYER_VERSION;
if ( $normalized_options !== $options ) {
update_option( 'fastlayer_options', $normalized_options );
}
}
public function activate( $network_wide = false ) {
$this->create_cache_directory();
$this->create_logs_directory();
$this->migrate_legacy_state();
$this->set_default_options( get_option( 'fastlayer_options', array() ) );
wp_clear_scheduled_hook( 'wp_fastlayer_preload_cache' );
if ( ! wp_next_scheduled( 'fastlayer_preload_cache' ) ) {
wp_schedule_event( time(), 'hourly', 'fastlayer_preload_cache' );
}
self::install_advanced_cache_dropin();
self::add_wp_cache_constant();
// Network activation must seed the site index for every existing site,
// so the drop-in can resolve each one before it has been visited. A
// site-level activation only writes the current site's entry.
if ( self::is_multisite_install() && $network_wide ) {
self::rebuild_multisite_index();
}
}
public static function get_default_options() {
return array(
'enable_cache' => '0',
'enable_preload' => '0',
'enable_minify' => '0',
'enable_minify_css' => '0',
'enable_minify_js' => '0',
'enable_instant_click' => '1',
'enable_lazyload' => '0',
'enable_lazyload_iframes' => '0',
'enable_lazyload_videos' => '0',
'enable_smart_lcp_optimization' => '0',
'enable_lcp_preload' => '1',
'enable_lcp_hero_background' => '1',
'enable_lcp_debug_mode' => '0',
'enable_webp' => '0',
'enable_bulk_webp_generation' => '1',
'enable_image_delivery' => '0',
'enable_cdn' => '0',
'cdn_cname' => '',
'enable_imagekit_cdn' => '0',
'imagekit_endpoint' => '',
'imagekit_origin_path_mode' => 'full_uploads_path',
'imagekit_auto_quality' => '1',
'imagekit_auto_format' => '1',
'imagekit_auto_format_mode' => 'f-auto',
'imagekit_max_oversize_ratio' => '1.5',
'imagekit_runtime_correction' => '1',
'imagekit_debug_overlay' => '0',
'webp_quality' => 80,
'enable_progressive_image_loading' => '0',
'enable_lqip_placeholders' => '1',
'lqip_quality' => 25,
'lqip_width' => 40,
'lqip_blur_intensity' => 20,
'lqip_fade_duration' => 250,
'enable_html_remove_comments' => '1',
'enable_html_remove_whitespace' => '1',
'enable_html_preserve_gutenberg_comments' => '1',
'enable_css_combine' => '0',
'enable_css_remove_comments' => '0',
'enable_css_remove_unused' => '0',
'enable_smart_gutenberg_css_optimization' => '1',
'enable_google_fonts_display' => '1',
'enable_google_fonts_preconnect' => '1',
'enable_google_fonts_combine' => '0',
'enable_local_font_display_swap' => '1',
'enable_elementor_icon_font_swap' => '1',
'enable_preload_fonts' => '1',
'preload_fonts_list' => '',
'enable_disable_dashicons' => '0',
'enable_css_inline' => '0',
'enable_css_delivery' => '0',
'enable_critical_css' => '0',
'critical_css_custom' => '',
'exclude_css_files' => '',
'enable_js_combine' => '0',
'enable_js_remove_comments' => '0',
'enable_js_defer' => '0',
'enable_js_async' => '0',
'enable_js_safe_mode' => '0',
'enable_js_smart_delay' => '0',
'enable_js_smart_delay_debug' => '0',
'exclude_js_files' => '',
'exclude_js_defer' => '',
'smart_js_delay_timeout' => '6000',
'smart_js_delay_exclusions' => '',
'debug_mode' => '0',
'db_clean_post_revisions' => '0',
'db_clean_trashed_posts' => '0',
'db_clean_spam_comments' => '0',
'db_clean_auto_drafts' => '0',
'db_clean_all_transients' => '0',
'db_clean_duplicated_postmeta' => '0',
'db_clean_duplicated_commentmeta' => '0',
'db_clean_duplicated_usermeta' => '0',
'db_clean_duplicated_termmeta' => '0',
'db_clean_expired_transients' => '0',
'db_clean_oembed_cache' => '0',
'db_clean_orphaned_postmeta' => '0',
'db_clean_orphaned_usermeta' => '0',
'db_clean_orphaned_termmeta' => '0',
'db_clean_orphaned_commentmeta' => '0',
'cache_expiration' => 10,
'exclude_urls' => '',
'exclude_cookies' => '',
'exclude_user_agents' => '',
'_version' => FASTLAYER_VERSION
);
}
private function set_default_options( $existing_options = array() ) {
$options = wp_parse_args( is_array( $existing_options ) ? $existing_options : array(), self::get_default_options() );
$options['_version'] = FASTLAYER_VERSION;
update_option( 'fastlayer_options', $options );
self::write_static_config( $options );
}
/**
* Writes the static config used by the advanced-cache.php drop-in.
*
* On a single-site install this is one config.json file. On multisite the
* options are per site, so each site gets sites/{blog_id}.json and a shared
* sites.json index maps a site's home URL to its id; the drop-in resolves
* the current request against that index (it cannot call
* get_current_blog_id() before ms-settings.php runs).
*
* The files live in FastLayer's own cache directory and only serve as a
* pre-bootstrap mirror of the options row, which stays the source of truth.
* They are plain JSON, not PHP, so nothing under wp-content/ is ever
* executed by including it. Every target path is forced to remain inside
* the cache directory, and a failed or partial write is recorded instead of
* being silenced, so a read-only directory raises a notice rather than
* silently leaving the drop-in on stale settings.
*
* @param array $options Plugin options (pre-migration form is fine too).
* @return bool True when the config file on disk matches the options.
*/
public static function write_static_config( $options ) {
$cache_dir = wp_normalize_path( FASTLAYER_CACHE_DIR );
if ( ! file_exists( $cache_dir ) ) {
wp_mkdir_p( $cache_dir );
}
self::ensure_directory_protection( $cache_dir );
$options = is_array( $options ) ? $options : array();
// The advanced-cache.php drop-in runs before WordPress is loaded, so it
// cannot call home_url() itself. Store the canonical hosts here so the
// drop-in can validate the client-supplied HTTP_HOST before it is used
// in a cache key. This key exists only in the generated config file; it
// is never written back to the fastlayer_options database row.
$options['_home_host'] = self::get_expected_hosts();
if ( self::is_multisite_install() ) {
return self::write_multisite_static_config( $cache_dir, $options );
}
$options['_blog_id'] = 1;
$config_file = wp_normalize_path( $cache_dir . 'config.json' );
if ( 0 !== strpos( $config_file, $cache_dir ) ) {
self::record_write_failure( 'config.json', 'the target path escaped the cache directory; no file was written.' );
return false;
}
if ( ! self::write_json_file( $config_file, $options ) ) {
self::record_write_failure( 'config.json', 'the file could not be written to ' . $config_file . '.' );
return false;
}
// A single-site install must not leave a multisite index behind: the
// drop-in prefers the index over config.json, so a stale index would
// shadow every later settings change.
self::remove_legacy_config_files( $cache_dir );
self::remove_multisite_artifacts( $cache_dir );
return true;
}
/**
* Writes the per-site config file and the network site index on multisite.
*
* Every site keeps its own file under sites/{blog_id}.json, while
* sites.json maps a site's home URL to its id. advanced-cache.php cannot
* call get_current_blog_id() (it runs before ms-settings.php), so it
* resolves the current site by matching the request host and path against
* that index. The index is additive: each write only adds or updates the
* current site's entry, so one site's save never clobbers another's.
*
* @param string $cache_dir Normalized cache directory (trailing slash).
* @param array $options Plugin options, already carrying _home_host.
* @return bool
*/
private static function write_multisite_static_config( $cache_dir, $options ) {
$blog_id = self::current_site_id();
if ( $blog_id <= 0 ) {
self::record_write_failure( 'config.json', 'the multisite site id could not be determined; no per-site file was written.' );
return false;
}
$sites_dir = wp_normalize_path( $cache_dir . 'sites/' );
if ( 0 !== strpos( $sites_dir, $cache_dir ) ) {
self::record_write_failure( 'config.json', 'the per-site directory escaped the cache directory; no file was written.' );
return false;
}
if ( ! file_exists( $sites_dir ) ) {
wp_mkdir_p( $sites_dir );
}
self::ensure_directory_protection( $sites_dir );
$home_url = self::site_home_url( $blog_id );
$options['_blog_id'] = $blog_id;
$options['_home_url'] = $home_url;
$site_file = wp_normalize_path( $sites_dir . $blog_id . '.json' );
if ( 0 !== strpos( $site_file, $cache_dir ) ) {
self::record_write_failure( 'config.json', 'the per-site config path escaped the cache directory; no file was written.' );
return false;
}
if ( ! self::write_json_file( $site_file, $options ) ) {
self::record_write_failure( 'config.json', 'the per-site config could not be written to ' . $site_file . '.' );
return false;
}
if ( '' !== $home_url ) {
self::update_site_index( wp_normalize_path( $cache_dir . 'sites.json' ), $home_url, $blog_id );
}
// A stale shared config must not remain: the drop-in falls back to
// config.json only when there is no index, and leaving one behind would
// let a single site's settings leak to the whole network.
$single_file = wp_normalize_path( $cache_dir . 'config.json' );
if ( file_exists( $single_file ) ) {
wp_delete_file( $single_file );
}
self::remove_legacy_config_files( $cache_dir );
return true;
}
/**
* Adds or updates one site's entry in the network index.
*
* A malformed or missing index is rebuilt from the sites table first, so a
* single site's write can never silently drop the other sites' entries. Any
* stale entry that still points at this site's previous home URL is removed,
* so a changed home URL or domain does not leave the old URL resolving here.
*
* @param string $index_file Absolute path to sites.json.
* @param string $home_url Untrailingslashed site home URL.
* @param int $blog_id Site id.
* @return bool
*/
private static function update_site_index( $index_file, $home_url, $blog_id ) {
$index_file = wp_normalize_path( $index_file );
if ( 0 !== strpos( $index_file, wp_normalize_path( FASTLAYER_CACHE_DIR ) ) ) {
return false;
}
$index = self::read_json_file( $index_file );
if ( ! is_array( $index ) ) {
// Missing or malformed: rebuild from the sites table instead of
// starting from an empty array, which would drop every other site.
self::rebuild_multisite_index();
$index = self::read_json_file( $index_file );
if ( ! is_array( $index ) ) {
$index = array();
}
}
$blog_id = (int) $blog_id;
$target = self::home_url_host_path( $home_url );
$kept = false;
foreach ( $index as $existing_home => $existing_id ) {
if ( (int) $existing_id !== $blog_id ) {
continue;
}
// Same site and same host+path: keep the existing key (it may differ
// only by scheme, which the drop-in deliberately ignores) instead of
// adding a duplicate.
if ( self::home_url_host_path( (string) $existing_home ) === $target ) {
$kept = true;
continue;
}
// Stale entry for this site's previous home URL.
unset( $index[ $existing_home ] );
}
if ( ! $kept ) {
$index[ $home_url ] = $blog_id;
}
return self::write_json_file( $index_file, $index );
}
/**
* True when the index contains an entry for the given site at its current
* home URL.
*
* The comparison ignores the scheme because the drop-in resolves a request
* by host and path only, and get_home_url() reports "https" whenever the
* request is over TLS even if the stored home URL is "http". Comparing the
* exact URLs would therefore rewrite a perfectly usable index on every
* scheme change.
*
* @param mixed $index Decoded index, or null when missing/invalid.
* @param int $blog_id Site id.
* @return bool
*/
private static function index_maps_site( $index, $blog_id ) {
if ( ! is_array( $index ) || empty( $index ) ) {
return false;
}
$blog_id = (int) $blog_id;
if ( $blog_id <= 0 ) {
return false;
}
$home = self::site_home_url( $blog_id );
if ( '' === $home ) {
// The home URL cannot be read; accept the site only if the index
// still lists it, so we do not rewrite for an unrelated reason.
return in_array( $blog_id, array_map( 'intval', $index ), true );
}
$target = self::home_url_host_path( $home );
foreach ( $index as $index_home => $site_id ) {
if ( (int) $site_id === $blog_id && self::home_url_host_path( (string) $index_home ) === $target ) {
return true;
}
}
return false;
}
/**
* Host + untrailingslashed path of a URL, used to compare home URLs
* regardless of scheme.
*
* @param string $url URL.
* @return string
*/
private static function home_url_host_path( $url ) {
$parts = wp_parse_url( (string) $url );
if ( ! is_array( $parts ) ) {
return '';
}
$host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : '';
$path = isset( $parts['path'] ) ? untrailingslashit( (string) $parts['path'] ) : '';
return $host . $path;
}
/**
* Refreshes the static config when a site's home or site URL changes.
*
* Hooked to update_option_home / update_option_siteurl. These actions fire
* in the changed site's own context (update_blog_option() switches to it
* first), so get_current_blog_id() names the right site. On single-site the
* same call refreshes config.json, whose trusted-host list is derived from
* the home and site URLs.
*/
public function on_home_option_updated() {
$options = get_option( 'fastlayer_options', array() );
if ( is_array( $options ) && ! empty( $options ) ) {
self::write_static_config( $options );
}
}
/**
* Rebuilds the whole network index from the sites table.
*
* Used on network activation, when the index is missing, and after the
* cache directory has been emptied. Reads each site's home URL with
* get_home_url() so mapped domains are indexed exactly as WordPress
* resolves them.
*
* @return bool
*/
private static function rebuild_multisite_index() {
if ( ! self::is_multisite_install() || ! function_exists( 'get_sites' ) ) {
return false;
}
$index_file = wp_normalize_path( FASTLAYER_CACHE_DIR . 'sites.json' );
$index = array();
foreach ( get_sites( array( 'number' => 0 ) ) as $site ) {
$blog_id = isset( $site->blog_id ) ? (int) $site->blog_id : 0;
if ( $blog_id <= 0 ) {
continue;
}
$home = self::site_home_url( $blog_id );
if ( '' !== $home ) {
$index[ $home ] = $blog_id;
}
}
if ( empty( $index ) ) {
return false;
}
return self::write_json_file( $index_file, $index );
}
/**
* Adds a newly created site to the network index.
*
* Hooked to wp_initialize_site. Without this a site created after network
* activation would only enter the index on its first front-end request, and
* until then the main site's index entry would answer for it.
*
* @param \WP_Site|int $new_site Site object (wp_initialize_site) or id.
*/
public function on_initialize_site( $new_site ) {
if ( ! self::is_multisite_install() ) {
return;
}
if ( is_object( $new_site ) && isset( $new_site->blog_id ) ) {
$blog_id = (int) $new_site->blog_id;
} else {
$blog_id = (int) $new_site;
}
if ( $blog_id <= 0 ) {
return;
}
$home = self::site_home_url( $blog_id );
if ( '' === $home ) {
return;
}
self::update_site_index( wp_normalize_path( FASTLAYER_CACHE_DIR . 'sites.json' ), $home, $blog_id );
}
/**
* Deletes the legacy executable config.php written before 1.1.0.
*
* @param string $cache_dir Normalized cache directory (trailing slash).
*/
private static function remove_legacy_config_files( $cache_dir ) {
$legacy_config = wp_normalize_path( $cache_dir . 'config.php' );
if ( file_exists( $legacy_config ) ) {
wp_delete_file( $legacy_config );
}
}
/**
* Removes a stale multisite index and per-site files on a single-site
* install (for example after a multisite network was reduced to one site).
*
* @param string $cache_dir Normalized cache directory (trailing slash).
*/
private static function remove_multisite_artifacts( $cache_dir ) {
$index_file = wp_normalize_path( $cache_dir . 'sites.json' );
if ( file_exists( $index_file ) ) {
wp_delete_file( $index_file );
}
$sites_dir = wp_normalize_path( $cache_dir . 'sites/' );
if ( ! is_dir( $sites_dir ) ) {
return;
}
try {
$iterator = new \DirectoryIterator( $sites_dir );
} catch ( \Exception $e ) {
return;
}
foreach ( $iterator as $item ) {
if ( $item->isDot() ) {
continue;
}
if ( $item->isFile() ) {
wp_delete_file( $item->getPathname() );
}
}
self::delete_empty_directory( $sites_dir );
}
/**
* True when the install is a multisite network.
*
* @return bool
*/
private static function is_multisite_install() {
return function_exists( 'is_multisite' ) && is_multisite();
}
/**
* The id of the site currently being rendered, or 1 on single-site.
*
* @return int
*/
private static function current_site_id() {
if ( function_exists( 'get_current_blog_id' ) ) {
return (int) get_current_blog_id();
}
return 1;
}
/**
* Untrailingslashed home URL for a site.
*
* @param int $blog_id Site id.
* @return string
*/
private static function site_home_url( $blog_id ) {
$blog_id = (int) $blog_id;
if ( $blog_id <= 0 ) {
return '';
}
// get_home_url( $blog_id ) switches to that blog and back even for the
// current site, which is wasteful on the per-request path. Use home_url()
// directly when the id is already current. The current id is read into a
// variable rather than compared inline so the cast is not mistaken for a
// literal by the Yoda-conditions sniff.
$current_blog_id = function_exists( 'get_current_blog_id' ) ? (int) get_current_blog_id() : 0;
$is_current = ( $blog_id === $current_blog_id );
if ( $is_current ) {
$home = home_url( '/' );
} else {
$home = function_exists( 'get_home_url' ) ? get_home_url( $blog_id ) : home_url( '/' );
}
if ( ! is_string( $home ) || '' === $home ) {
return '';
}
return untrailingslashit( $home );
}
/**
* Writes an array as JSON and verifies the bytes that landed on disk.
*
* @param string $path Absolute target path.
* @param array $data Data to encode.
* @return bool
*/
private static function write_json_file( $path, $data ) {
$content = wp_json_encode( $data );
if ( false === $content ) {
return false;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- the config directory is not reachable through WP_Filesystem in every path that writes it (activation and pre-init); the write is verified byte-for-byte below.
$written = @file_put_contents( $path, $content, LOCK_EX );
if ( false === $written || strlen( $content ) !== $written ) {
return false;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- read-back verification of the file just written; WP_Filesystem is not always initialised on this path.
return file_get_contents( $path ) === $content;
}
/**
* Reads and decodes a JSON file.
*
* @param string $path Absolute path.
* @return array|null Decoded array, or null when missing or invalid.
*/
private static function read_json_file( $path ) {
if ( ! file_exists( $path ) ) {
return null;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading the small network index; WP_Filesystem is not initialised on every front-end request.
$raw = file_get_contents( $path );
if ( ! is_string( $raw ) || '' === $raw ) {
return null;
}
$decoded = json_decode( $raw, true );
return is_array( $decoded ) ? $decoded : null;
}
/**
* Returns the lowercase hostnames that are trusted for cache purposes.
*/
public static function get_expected_hosts() {
$hosts = array();
foreach ( array( home_url( '/' ), site_url( '/' ) ) as $candidate ) {
$host = wp_parse_url( $candidate, PHP_URL_HOST );
if ( is_string( $host ) && '' !== $host ) {
$hosts[] = strtolower( $host );
}
}
return array_values( array_unique( $hosts ) );
}
/**
* Converts an internal URL into an absolute filesystem path.
*
* Every module uses this one implementation so a URL is never mapped with
* the wrong assumption that site_url() equals ABSPATH. The host must belong
* to this site, the query string and fragment are dropped, and any ".." or
* NUL byte aborts the conversion. The longest matching base URL (content,
* includes, admin, plugins, uploads, then the site root) wins, so a custom
* content directory or a subdirectory install maps to the right folder.
*
* @param string $url Absolute or site-relative URL.
* @return string|false Absolute path, or false when the URL is not a safe
* internal URL.
*/
public static function url_to_path( $url ) {
if ( ! is_string( $url ) || '' === $url ) {
return false;
}
$url = esc_url_raw( $url );
if ( '' === $url ) {
return false;
}
$parsed = wp_parse_url( $url );
if ( ! is_array( $parsed ) || ! isset( $parsed['path'] ) || '' === $parsed['path'] ) {
return false;
}
// A host that is not one of ours is never mapped to a local file.
if ( ! empty( $parsed['host'] ) && ! self::url_host_is_internal( $url ) ) {
return false;
}
$request_path = rawurldecode( (string) $parsed['path'] );
if ( self::path_has_traversal( $request_path ) ) {
return false;
}
// No host: resolve a site-relative path against the WordPress root.
if ( empty( $parsed['host'] ) ) {
$relative = ltrim( $request_path, '/' );
if ( '' === $relative ) {
return false;
}
$home_path = function_exists( 'get_home_path' ) ? get_home_path() : ( defined( 'ABSPATH' ) ? constant( 'ABSPATH' ) : '' );
return wp_normalize_path( trailingslashit( $home_path ) . $relative );
}
$bases = array();
$register_base = static function ( $base_url, $base_dir ) use ( &$bases ) {
if ( ! is_string( $base_url ) || '' === $base_url || ! is_string( $base_dir ) || '' === $base_dir ) {
return;
}
$base_path = wp_parse_url( $base_url, PHP_URL_PATH );
$base_path = is_string( $base_path ) ? untrailingslashit( rawurldecode( $base_path ) ) : '';
$bases[] = array(
'path' => $base_path,
'dir' => trailingslashit( wp_normalize_path( $base_dir ) ),
);
};
$upload_dir = wp_upload_dir();
if ( empty( $upload_dir['error'] ) ) {
$register_base( $upload_dir['baseurl'], $upload_dir['basedir'] );
}
if ( function_exists( 'get_theme_root' ) && function_exists( 'get_theme_root_uri' ) ) {
$register_base( get_theme_root_uri(), get_theme_root() );
}
$plugin_dir = dirname( plugin_dir_path( FASTLAYER_FILE ) );
$register_base( plugins_url(), $plugin_dir );
$home_path = function_exists( 'get_home_path' ) ? get_home_path() : ( defined( 'ABSPATH' ) ? constant( 'ABSPATH' ) : '' );
if ( '' !== $home_path ) {
$content_dir = defined( 'WP_CONTENT_DIR' ) ? constant( 'WP_CONTENT_DIR' ) : trailingslashit( $home_path ) . 'wp-content';
$register_base( content_url(), $content_dir );
$inc_dir = defined( 'WPINC' ) ? trailingslashit( $home_path ) . constant( 'WPINC' ) : trailingslashit( $home_path ) . 'wp-includes';
$register_base( includes_url(), $inc_dir );
$register_base( admin_url(), trailingslashit( $home_path ) . 'wp-admin' );
$register_base( site_url(), $home_path );
$register_base( home_url(), $home_path );
}
$best = null;
foreach ( $bases as $candidate ) {
$base_path = $candidate['path'];
if ( '' !== $base_path && $request_path !== $base_path && 0 !== strpos( $request_path, $base_path . '/' ) ) {
continue;
}
if ( null === $best || strlen( $base_path ) > strlen( $best['path'] ) ) {
$best = $candidate;
}
}
if ( null === $best ) {
return false;
}
$relative = ltrim( substr( $request_path, strlen( $best['path'] ) ), '/' );
if ( '' === $relative ) {
return untrailingslashit( $best['dir'] );
}
return wp_normalize_path( $best['dir'] . $relative );
}
/**
* True when a URL path contains a directory traversal or NUL byte.
*
* Backslashes are treated as separators too, so a Windows-style "..\\"
* segment is rejected as well as the usual "../".
*
* @param string $path URL path component.
* @return bool
*/
public static function path_has_traversal( $path ) {
if ( ! is_string( $path ) || '' === $path ) {
return true;
}
if ( false !== strpos( $path, "\0" ) ) {
return true;
}
// Iteratively decode percent-encoding to catch multi-encoded traversal (e.g. %252e) and encoded NULs.
$decoded_path = $path;
for ( $i = 0; $i < 3; $i++ ) {
if ( false === strpos( $decoded_path, '%' ) ) {
break;
}
$next = rawurldecode( $decoded_path );
if ( $next === $decoded_path ) {
break;
}
$decoded_path = $next;
}
if ( false !== strpos( $decoded_path, "\0" ) ) {
return true;
}
$segments = explode( '/', str_replace( '\\', '/', $decoded_path ) );
foreach ( $segments as $segment ) {
if ( '..' === $segment ) {
return true;
}
}
return false;
}
/**
* True when a URL's host belongs to this WordPress install.
*
* @param string $url Full URL.
* @return bool
*/
public static function url_host_is_internal( $url ) {
if ( ! is_string( $url ) || '' === $url ) {
return false;
}
$host = wp_parse_url( $url, PHP_URL_HOST );
if ( ! is_string( $host ) || '' === $host ) {
return false;
}
$host = strtolower( $host );
foreach ( self::get_expected_hosts() as $expected ) {
if ( $host === $expected ) {
return true;
}
}
return false;
}
/**
* Keeps the generated static config in sync with the options row.
*
* The two hooks pass different signatures:
* add_option_fastlayer_options ( $option, $value )
* update_option_fastlayer_options ( $option, $old_value, $value )
*
* The third argument therefore holds the new value on the update path,
* while the second holds it on the add path. Prefer the third argument
* when it is an array so the update path does not write the stale value.
*/
public function sync_static_config( $param1, $param2 = null, $param3 = null ) {
$value = is_array( $param3 ) ? $param3 : $param2;
if ( is_array( $value ) && ! empty( $value ) ) {
self::write_static_config( $value );
}
}
public function deactivate() {
wp_clear_scheduled_hook( 'fastlayer_preload_cache' );
// A batch event can be left pending when the plugin is switched off, so
// it is cleared here too rather than firing against a disabled plugin.
wp_clear_scheduled_hook( Page_Cache::PRELOAD_BATCH_HOOK );
// Also clear the legacy hook names an upgraded install may still have.
wp_clear_scheduled_hook( 'wp_fastlayer_preload_cache' );
wp_clear_scheduled_hook( 'wp_fastlayer_preload_batch' );
self::remove_advanced_cache_dropin();
self::remove_wp_cache_constant();
// Clear FastLayer's WebP rewrite directives too: once the plugin is
// unloaded the init-time self-heal can no longer run, so the WebP
// rules would otherwise stay active. The call is guarded and
// best-effort so a deactivation can never fatal.
if ( class_exists( __NAMESPACE__ . '\\Webp' ) ) {
Webp::get_instance()->remove_rewrite_rules_on_deactivate();
}
}
/**
* Determines whether an advanced-cache.php drop-in belongs to FastLayer.
*
* Ownership is proven by the FastLayer marker written into the drop-in, by
* an exact match against the bundled drop-in, or by the drop-in declaring
* FastLayer's own cache functions. The last signal matters for installs
* created before the marker existed: without it those drop-ins are mistaken
* for a foreign plugin's, so they are never refreshed on upgrade and never
* removed on deactivation. Anything else is treated as foreign and is never
* modified or deleted.
*/
public static function is_our_advanced_cache_dropin( $file ) {
if ( ! is_file( $file ) || ! is_readable( $file ) ) {
return false;
}
$source = FASTLAYER_PATH . 'advanced-cache.php';
if ( is_readable( $source ) && md5_file( $file ) === md5_file( $source ) ) {
return true;
}
$head = (string) file_get_contents( $file );
if ( false !== strpos( $head, self::ADVANCED_CACHE_DROPIN_MARKER ) ) {
return true;
}
// A drop-in that declares FastLayer's cache functions is ours, even if
// it predates the marker (including the legacy wp_fastlayer_* names).
return false !== strpos( $head, 'function fastlayer_should_skip_cache' )
|| false !== strpos( $head, 'function wp_fastlayer_should_skip_cache' );
}
public static function install_advanced_cache_dropin() {
$source = FASTLAYER_PATH . 'advanced-cache.php';
$dest = self::ADVANCED_CACHE_DROPIN;
if ( ! file_exists( $source ) ) {
return;
}
// Never overwrite a drop-in owned by another caching plugin.
if ( file_exists( $dest ) && ! self::is_our_advanced_cache_dropin( $dest ) ) {
\fastlayer_debug_log( 'WP FastLayer: a foreign advanced-cache.php drop-in is installed; leaving it untouched.' );
return;
}
@copy( $source, $dest );
}
public static function remove_advanced_cache_dropin() {
$dest = self::ADVANCED_CACHE_DROPIN;
if ( file_exists( $dest ) && self::is_our_advanced_cache_dropin( $dest ) ) {
wp_delete_file( $dest );
}
}
/**
* Reads wp-config.php and refuses anything that is not safely editable.
*
* is_writable() also returns true for a write-only file, so a config that
* FastLayer may write but not read reaches file_get_contents() as false.
* That false must never be treated as content: handing it to preg_replace()
* yields an empty string, which would then be written back over the config
* and leave the site with a zero-byte wp-config.php. Every read failure is
* therefore reported and the file is left untouched.
*
* @param string $config_file Absolute path to wp-config.php.
* @return string|false Contents, or false when the file must not be touched.
*/
private static function read_wp_config( $config_file ) {
if ( ! is_file( $config_file ) ) {
self::record_write_failure( 'wp-config.php', 'the file does not exist; it was not modified.' );
return false;
}
if ( ! is_readable( $config_file ) ) {
self::record_write_failure( 'wp-config.php', 'the file is not readable, so it was not modified.' );
return false;
}
if ( ! wp_is_writable( $config_file ) ) {
self::record_write_failure( 'wp-config.php', 'the file is not writable, so it was not modified.' );
return false;
}
$content = file_get_contents( $config_file );
if ( ! is_string( $content ) || '' === trim( $content ) ) {
self::record_write_failure( 'wp-config.php', 'the file could not be read, or is empty; it was not modified.' );
return false;
}
if ( 0 !== strpos( $content, ' guards because a
* directive the running server does not understand raises a 500, which
* would be worse than the exposure it prevents. mod_authz_core carries
* "Require" on Apache 2.4, and the legacy "Order"/"Deny" pair applies only
* when it is absent on Apache 2.2.
*/
private static function ensure_directory_protection( $dir ) {
$dir = trailingslashit( wp_normalize_path( $dir ) );
if ( '' === $dir || ! is_dir( $dir ) ) {
return;
}
$index_file = $dir . 'index.php';
if ( ! file_exists( $index_file ) ) {
@file_put_contents( $index_file, "\n"
. "Require all denied\n"
. "\n"
. "\n"
. "Order allow,deny\n"
. "Deny from all\n"
. "\n";
$current = file_exists( $htaccess_file ) ? (string) file_get_contents( $htaccess_file ) : '';
$legacy_rules = "Deny from all\nRequire all denied\n";
// Write when missing, refresh when the file is FastLayer's own (either
// carrying the marker or the exact legacy string written before), and
// leave a hand-edited .htaccess untouched.
$is_ours = ( '' === $current || $legacy_rules === $current || false !== strpos( $current, $marker ) );
if ( $is_ours && $current !== $rules ) {
@file_put_contents( $htaccess_file, $rules, LOCK_EX );
}
}
private function create_cache_directory() {
$cache_dir = FASTLAYER_CACHE_DIR;
if ( ! file_exists( $cache_dir ) ) {
wp_mkdir_p( $cache_dir );
}
self::ensure_directory_protection( $cache_dir );
}
/**
* Returns an initialised WP_Filesystem instance, or false when unavailable.
*
* Initialised once per request and cached, because WP_Filesystem() performs
* a write test on every call.
*
* @return \WP_Filesystem_Base|false
*/
private static function get_filesystem() {
static $filesystem = null;
if ( null !== $filesystem ) {
return $filesystem;
}
$filesystem = false;
global $wp_filesystem;
if ( ! ( $wp_filesystem instanceof \WP_Filesystem_Base ) ) {
if ( ! function_exists( 'WP_Filesystem' ) ) {
$home_path = function_exists( 'get_home_path' ) ? get_home_path() : ( defined( 'ABSPATH' ) ? constant( 'ABSPATH' ) : '' );
$filesystem_file = trailingslashit( $home_path ) . 'wp-admin/includes/file.php';
if ( ! file_exists( $filesystem_file ) ) {
return $filesystem;
}
require_once $filesystem_file;
}
if ( ! WP_Filesystem() ) {
return $filesystem;
}
}
if ( $wp_filesystem instanceof \WP_Filesystem_Base ) {
$filesystem = $wp_filesystem;
}
return $filesystem;
}
/**
* Removes an empty directory through the WordPress filesystem API.
*
* rmdir() is not called directly: WP_Filesystem is the supported filesystem
* abstraction (and what the WordPress.org Plugin Check expects). When the
* filesystem cannot be initialised the directory is left in place rather
* than falling back to a raw PHP call.
*
* @param string $directory Absolute path to the (empty) directory.
* @return bool True when the directory was removed.
*/
public static function delete_empty_directory( $directory ) {
$directory = wp_normalize_path( (string) $directory );
if ( '' === $directory || ! is_dir( $directory ) ) {
return false;
}
$filesystem = self::get_filesystem();
if ( false === $filesystem ) {
return false;
}
return (bool) $filesystem->rmdir( $directory );
}
private function create_logs_directory() {
// Actively clean up any legacy public log files in uploads/ to prevent direct HTTP access on Nginx.
$upload_dir = wp_upload_dir();
$legacy_upload_log_dir = trailingslashit( $upload_dir['basedir'] ) . 'wp-fastlayer-logs/';
if ( is_dir( $legacy_upload_log_dir ) ) {
$legacy_upload_log = $legacy_upload_log_dir . 'performance.log';
if ( file_exists( $legacy_upload_log ) ) {
wp_delete_file( $legacy_upload_log );
}
foreach ( array( '.htaccess', 'index.php' ) as $protection_file ) {
$p_file = $legacy_upload_log_dir . $protection_file;
if ( file_exists( $p_file ) ) {
wp_delete_file( $p_file );
}
}
self::delete_empty_directory( $legacy_upload_log_dir );
}
$old_log_dir = FASTLAYER_PATH . 'logs/';
if ( is_dir( $old_log_dir ) ) {
$old_log_file = $old_log_dir . 'performance.log';
if ( file_exists( $old_log_file ) ) {
wp_delete_file( $old_log_file );
}
self::delete_empty_directory( $old_log_dir );
}
}
public function log( $message, $type = 'info' ) {
if ( ! $this->is_debug_mode ) {
return;
}
\fastlayer_debug_log( sprintf( '[WP FastLayer] [%s] %s', strtoupper( $type ), $message ) );
}
}