0 ? $original : $post_id; foreach ( self::index_experiments() as $experiment ) { if ( (int) ( $experiment['post_id'] ?? 0 ) !== $target ) { continue; } self::delete_tree( self::variant_root( (int) $experiment['id'] ) ); self::delete_shared( (string) ( $experiment['path'] ?? '' ) ); } } /** * Remove rules and variant files. Used on deactivation and uninstall. */ public static function uninstall(): void { self::write_block( '' ); self::delete_tree( self::cache_root() ); } /** * Whether WP Fastest Cache is installed and active. * * @return bool */ public static function is_active(): bool { if ( ! is_readable( Locations::plugin_file( self::PLUGIN_FILE ) ) ) { return false; } if ( class_exists( 'WpFastestCache', false ) ) { return true; } $active = function_exists( 'get_option' ) ? get_option( 'active_plugins', array() ) : array(); if ( is_array( $active ) && in_array( self::PLUGIN_FILE, $active, true ) ) { return true; } if ( function_exists( 'is_multisite' ) && is_multisite() && function_exists( 'get_site_option' ) ) { $network = get_site_option( 'active_sitewide_plugins', array() ); if ( is_array( $network ) && isset( $network[ self::PLUGIN_FILE ] ) ) { return true; } } return false; } /** * Output-buffer callback. Stores one file for this variant and drops the shared file. * * @param string $html Page HTML. * @return string */ public static function capture( string $html ): string { $job = $GLOBALS['wpab_fc_job'] ?? null; if ( ! is_array( $job ) ) { return $html; } $path = (string) ( $job['request_path'] ?? '' ); self::delete_shared( $path ); if ( empty( $job['store'] ) ) { return $html; } $experiment_id = (int) ( $job['experiment_id'] ?? 0 ); $variant_id = (int) ( $job['variant_id'] ?? 0 ); $relative = (string) ( $job['relative'] ?? '' ); if ( $experiment_id < 1 || $variant_id < 1 || null === self::relative_path( '/' . $relative, '' ) ) { return $html; } if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) { return $html; } if ( function_exists( 'is_user_logged_in' ) && is_user_logged_in() ) { return $html; } if ( function_exists( 'http_response_code' ) && 200 !== (int) http_response_code() ) { return $html; } if ( function_exists( 'is_404' ) && is_404() ) { return $html; } if ( function_exists( 'is_feed' ) && is_feed() ) { return $html; } if ( str_contains( $html, '' ) || str_contains( $html, 'wp-die-message' ) ) { return $html; } if ( ! preg_match( '/]/i', $html ) || ! str_contains( strtolower( $html ), '' ) ) { return $html; } $dir = self::variant_root( $experiment_id ) . '/' . $variant_id; if ( '' !== $relative ) { $dir .= '/' . $relative; } if ( ! self::prepare_dir( $dir ) ) { return $html; } $target = $dir . '/index.html'; $tmp = $target . '.' . bin2hex( random_bytes( 4 ) ) . '.tmp'; if ( ! Filesystem::put( $tmp, $html ) ) { return $html; } if ( ! Filesystem::move( $tmp, $target ) ) { wp_delete_file( $tmp ); } return $html; } /** * Path under the home URL, or null when it is not safe to use in a filename. * * An empty string is the home page. * * @param string $request_path Absolute request path. * @param string $home_path Home path. "/" is the site root. * @return string|null */ public static function relative_path( string $request_path, string $home_path ): ?string { $home = '/' === $home_path ? '' : rtrim( $home_path, '/' ); $relative = $request_path; if ( '' !== $home && str_starts_with( $request_path, $home ) ) { $relative = substr( $request_path, strlen( $home ) ); } $relative = trim( (string) $relative, '/' ); if ( str_contains( $relative, '..' ) || preg_match( '/[\x00-\x1F\x7F\s#"\'<>\\\\?]/', $relative ) ) { return null; } return $relative; } /** * Htaccess block. An empty string means there is nothing to serve. * * @param array $entries Live experiments. * @param string $home_path Home path. * @param string $content_dir Absolute wp-content directory. * @param string $url_prefix URL prefix, empty at the domain root. * @return string */ public static function rules( array $entries, string $home_path, string $content_dir, string $url_prefix ): string { $lines = array(); foreach ( $entries as $entry ) { $id = (int) ( $entry['id'] ?? 0 ); $path = (string) ( $entry['path'] ?? '' ); if ( $id < 1 || '' === $path ) { continue; } $relative = self::relative_path( $path, $home_path ); if ( null === $relative ) { continue; } $pattern = '' === $relative ? '^$' : '^' . preg_quote( $relative, '~' ) . '/?$'; $suffix = '' === $relative ? '' : '/' . $relative; $file = $content_dir . '/cache/wpab/' . $id . '/%{ENV:WPAB_VARIANT}' . $suffix . '/index.html'; $url = $url_prefix . '/wp-content/cache/wpab/' . $id . '/%{ENV:WPAB_VARIANT}' . $suffix . '/index.html'; $lines[] = 'RewriteCond %{REQUEST_METHOD} =GET'; $lines[] = 'RewriteCond %{HTTP:Cookie} !wordpress_logged_in_'; $lines[] = 'RewriteCond %{HTTP:Cookie} !wpab_preview'; $lines[] = 'RewriteCond %{QUERY_STRING} !(^|&)wpab_'; $lines[] = 'RewriteCond %{HTTP:Cookie} wpab_assignment_' . $id . '=([0-9]+)'; $lines[] = 'RewriteRule ' . $pattern . ' - [E=WPAB_VARIANT:%1]'; $lines[] = 'RewriteCond %{ENV:WPAB_VARIANT} ^[0-9]+$'; $lines[] = 'RewriteCond "' . $file . '" -f'; $lines[] = 'RewriteRule ' . $pattern . ' ' . $url . ' [L]'; $lines[] = 'RewriteRule ' . $pattern . ' ' . $url_prefix . '/index.php [L]'; } if ( array() === $lines ) { return ''; } $body = self::BEGIN . "\n"; $body .= "# One cached page per variant for WP Fastest Cache.\n"; $body .= "\n"; $body .= "RewriteEngine On\n"; $body .= implode( "\n", $lines ) . "\n"; $body .= "\n"; $body .= self::END . "\n"; return $body; } /** * Insert, replace, or remove the managed block. * * @param string $contents Existing htaccess. * @param string $block Replacement block, or empty to remove it. * @return string */ public static function apply_block( string $contents, string $block ): string { $stripped = preg_replace( '/\n?# BEGIN WPAB Cache.*?# END WPAB Cache\n?/s', "\n", $contents ); if ( ! is_string( $stripped ) ) { $stripped = $contents; } $stripped = preg_replace( "/\n{3,}/", "\n\n", $stripped ); $stripped = is_string( $stripped ) ? ltrim( $stripped, "\n" ) : $contents; $block = trim( $block ); if ( '' === $block ) { return ltrim( $stripped, "\n" ); } $insertion = $block . "\n\n"; foreach ( array( '# BEGIN WpFastestCache', '# BEGIN WordPress' ) as $marker ) { $pos = strpos( $stripped, $marker ); if ( false !== $pos ) { return substr( $stripped, 0, $pos ) . $insertion . substr( $stripped, $pos ); } } return $insertion . ltrim( $stripped, "\n" ); } /** * Delete the single URL file WP Fastest Cache would serve to every visitor. * * @param string $request_path Absolute path, for example "/blog/hello-world". */ public static function delete_shared( string $request_path ): void { $content = Locations::content_dir(); if ( '' === $content || str_contains( $request_path, '..' ) ) { return; } $path = '/' . trim( $request_path, '/' ); foreach ( array( 'all', 'wpfc-mobile-cache' ) as $bucket ) { $base = '/' === $path ? $content . '/cache/' . $bucket : $content . '/cache/' . $bucket . $path; foreach ( array( 'index.html', 'index.html.gz', 'index.json', 'index.xml' ) as $name ) { $file = $base . '/' . $name; if ( is_file( $file ) ) { wp_delete_file( $file ); } } } } /** * Rebuild rules from the runtime index. * * @param bool $purge Delete variant files before rewriting the rules. */ private static function sync_files( bool $purge ): void { if ( ! self::is_active() || ! function_exists( 'wp_start_template_enhancement_output_buffer' ) ) { self::write_block( '' ); self::delete_tree( self::cache_root() ); return; } if ( $purge ) { self::delete_tree( self::cache_root() ); } $entries = array(); $live = array(); $now = time(); foreach ( self::index_experiments() as $experiment ) { $id = (int) ( $experiment['id'] ?? 0 ); if ( $id < 1 ) { continue; } $start = isset( $experiment['start'] ) ? (int) $experiment['start'] : null; $end = isset( $experiment['end'] ) ? (int) $experiment['end'] : null; $active = Runtime\is_within_schedule( (string) ( $experiment['status'] ?? '' ), $start, $end, $now ); if ( ! $active ) { self::delete_tree( self::variant_root( $id ) ); continue; } $entries[] = array( 'id' => $id, 'path' => (string) ( $experiment['path'] ?? '' ), ); $live[] = $id; self::delete_shared( (string) ( $experiment['path'] ?? '' ) ); } self::delete_stale_variant_dirs( $live ); self::write_block( self::rules( $entries, self::home_path(), Locations::content_dir(), self::url_prefix() ) ); } /** * Experiments stored in the runtime index. * * @return array> */ private static function index_experiments(): array { if ( ! class_exists( Runtime\Runtime_Index::class ) ) { return array(); } $file = Runtime\Runtime_Index::path(); if ( ! is_readable( $file ) ) { return array(); } $index = json_decode( Local_File::read( $file ), true ); if ( ! is_array( $index ) || ! isset( $index['experiments'] ) || ! is_array( $index['experiments'] ) ) { return array(); } $experiments = array(); foreach ( $index['experiments'] as $experiment ) { if ( is_array( $experiment ) ) { $experiments[] = $experiment; } } return $experiments; } /** * Home path. "/" means the domain root. * * @return string */ public static function home_path(): string { if ( ! function_exists( 'home_url' ) ) { return '/'; } $path = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ); $path = '/' . trim( $path, '/' ); return '/' === $path ? '/' : untrailingslashit( $path ); } /** * URL prefix used in rewrite substitutions. * * @return string */ private static function url_prefix(): string { $home = self::home_path(); return '/' === $home ? '' : $home; } /** * Write the managed block when the file would change. * * @param string $block Block, or empty to remove it. */ private static function write_block( string $block ): void { if ( ! Filesystem::ready() ) { return; } $file = get_home_path() . '.htaccess'; if ( ! is_readable( $file ) || ! Filesystem::is_writable( $file ) ) { if ( '' !== trim( $block ) ) { Logger::write( 'WP Fastest Cache variation rules could not be written to .htaccess.' ); } return; } $current = Filesystem::get( $file ); $next = self::apply_block( $current, $block ); if ( $next === $current ) { return; } $tmp = $file . '.wpab-tmp'; if ( ! Filesystem::put( $tmp, $next ) ) { Logger::write( 'WP Fastest Cache variation rules could not be written to .htaccess.' ); return; } if ( ! Filesystem::move( $tmp, $file ) ) { wp_delete_file( $tmp ); Logger::write( 'WP Fastest Cache variation rules could not be published to .htaccess.' ); } } /** * Remove variant directories that are not in the live set. * * @param int[] $live_ids Experiment IDs that may keep their files. */ private static function delete_stale_variant_dirs( array $live_ids ): void { $root = self::cache_root(); if ( ! is_dir( $root ) ) { return; } $items = scandir( $root ); if ( ! is_array( $items ) ) { return; } foreach ( $items as $item ) { if ( ! preg_match( '/^[1-9][0-9]*$/', $item ) ) { continue; } if ( ! in_array( (int) $item, $live_ids, true ) ) { self::delete_tree( $root . '/' . $item ); } } } /** * Root directory for every variant file. * * @return string */ private static function cache_root(): string { return Locations::content_dir() . '/cache/wpab'; } /** * Directory for one experiment. * * @param int $experiment_id Experiment ID. * @return string */ private static function variant_root( int $experiment_id ): string { return self::cache_root() . '/' . max( 0, $experiment_id ); } /** * Create a directory that stays inside the variant cache root. * * @param string $dir Absolute directory. * @return bool */ private static function prepare_dir( string $dir ): bool { if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { return false; } return self::inside_cache( $dir ); } /** * Whether a directory is inside wp-content/cache/wpab. * * @param string $dir Absolute directory. * @return bool */ private static function inside_cache( string $dir ): bool { $root = self::cache_root(); if ( ! is_dir( $root ) ) { return false; } $real_root = realpath( $root ); $real_dir = realpath( $dir ); if ( false === $real_root || false === $real_dir ) { return false; } return $real_dir === $real_root || str_starts_with( $real_dir, $real_root . DIRECTORY_SEPARATOR ); } /** * Delete a directory tree inside the variant cache root. * * @param string $dir Absolute directory. */ private static function delete_tree( string $dir ): void { if ( ! is_dir( $dir ) ) { return; } if ( ! self::inside_cache( $dir ) && realpath( $dir ) !== realpath( self::cache_root() ) ) { return; } $items = scandir( $dir ); if ( ! is_array( $items ) ) { return; } foreach ( $items as $item ) { if ( '.' === $item || '..' === $item ) { continue; } $path = $dir . '/' . $item; if ( is_dir( $path ) ) { self::delete_children( $path ); continue; } wp_delete_file( $path ); } if ( realpath( $dir ) !== realpath( self::cache_root() ) ) { Filesystem::rmdir( $dir ); } } /** * Delete a subdirectory that has already been checked via its parent. * * @param string $dir Absolute directory. */ private static function delete_children( string $dir ): void { $items = scandir( $dir ); if ( ! is_array( $items ) ) { return; } foreach ( $items as $item ) { if ( '.' === $item || '..' === $item ) { continue; } $path = $dir . '/' . $item; if ( is_dir( $path ) ) { self::delete_children( $path ); continue; } wp_delete_file( $path ); } Filesystem::rmdir( $dir ); } }