>|null */ private static $installed = null; /** * Whether this site has must-use plugins (set with the installed list). * * @var bool */ private static $has_mu = false; /** * Files loaded in this request (normalized path => true), read once. * * @var array|null */ private static $included = null; /** * Installed plugins' headers (get_plugins()), kept when only active states change. * * @var array|null */ private static $raw_plugins = null; /** * Prefix candidates, built once per request. * * @var array>|null */ private static $candidates = null; /** * Core option names from core-options.php. * * @var string[]|null */ private static $core_options = null; /** * Lowercased core names per context => lookup hash. * * @var array> */ private static $core_hash = array(); /** * Guess the owner of an option or cron hook name. * * @since 1.0.0 * @param string $name Option name or cron hook. * @param string $context `option` or `cron_hook`. * @param string[] $core_names Extra exact names known to be core. Optional: core options * (src/Support/core-options.php) and cron hooks (CORE_CRON_HOOKS) * are built in. * @return array{type: string, slug: string, name: string, state: string, confidence: string} */ public static function guess( string $name, string $context = 'option', array $core_names = array() ): array { $context = 'cron_hook' === $context ? 'cron_hook' : 'option'; $key = strtolower( trim( $name ) ); if ( 'option' === $context ) { $key = (string) preg_replace( '/^_(site_)?transient_(timeout_)?/', '', $key ); } $guess = self::find( $key, $context, $core_names ); /** * Filter the owner guess for an option or cron hook. Return the same array shape; unknown * values are read as `unknown`. * * @since 1.0.0 * @param array $guess { type: core|woocommerce|plugin|theme|unknown, slug, name, * state: active|inactive|not-installed|unknown, * confidence: exact|likely|unknown }. * @param string $name Option name or cron hook, as given. * @param string $context `option` or `cron_hook`. */ $filtered = apply_filters( 'apwc_owner_guess', $guess, $name, $context ); return self::normalize( is_array( $filtered ) ? $filtered : $guess ); } /** * Forget everything cached (plugin headers, states, candidates, core lookups): after * installing plugins, and in tests. State changes later in a request are handled by refresh(). * * @since 1.0.0 * @return void */ public static function flush() { self::$raw_plugins = null; self::refresh(); self::$has_mu = false; self::$core_hash = array(); } /** * Recompute active states on the next guess (a plugin (de)activated, the theme switched, or * another site switched to), keeping the installed plugin headers, which don't change then. * * @since 1.0.0 * @return void */ public static function refresh() { self::$installed = null; self::$included = null; self::$candidates = null; } /** * Forget the per-request lists when what they describe changes later in the request: a plugin * (de)activated, the theme switched, or another site switched to on multisite. Hooked only * once Owners builds its lists, so requests that never guess an owner pay nothing. * * @return void */ private static function watch() { foreach ( array( 'activated_plugin', 'deactivated_plugin', 'switch_theme', 'switch_blog' ) as $hook ) { if ( false === has_action( $hook, array( __CLASS__, 'refresh' ) ) ) { add_action( $hook, array( __CLASS__, 'refresh' ), 0, 0 ); } } } /** * The unknown answer. * * @since 1.0.0 * @return array{type: string, slug: string, name: string, state: string, confidence: string} */ public static function unknown(): array { return array( 'type' => 'unknown', 'slug' => '', 'name' => '', 'state' => 'unknown', 'confidence' => 'unknown', ); } /** * Find the best match. * * @param string $key Lowercased name. * @param string $context `option` or `cron_hook`. * @param string[] $core_names Extra core names. * @return array{type: string, slug: string, name: string, state: string, confidence: string} */ private static function find( string $key, string $context, array $core_names ): array { if ( '' === $key ) { return self::unknown(); } // 1. WordPress core, exact names. $is_core = isset( self::core_hash( $context )[ $key ] ); if ( ! $is_core && $core_names ) { $is_core = in_array( $key, array_map( 'strtolower', array_filter( $core_names, 'is_string' ) ), true ); } if ( $is_core || ( 'option' === $context && self::is_core_pattern( $key ) ) ) { return self::owner( 'core', 'core', 'WordPress', 'active', 'exact' ); } // 2. theme_mods_{stylesheet} belongs to that theme. if ( 'option' === $context && 0 === strpos( $key, 'theme_mods_' ) ) { $slug = substr( $key, strlen( 'theme_mods_' ) ); foreach ( self::installed() as $candidate ) { if ( 'theme' === $candidate['type'] && $candidate['slug'] === $slug ) { return self::owner( 'theme', $slug, $candidate['name'], $candidate['state'], 'exact' ); } } return self::owner( 'theme', $slug, $slug, 'not-installed', 'exact' ); } // 3. Longest matching prefix over every other source (ties keep the earlier source: // this plugin, then WooCommerce and Action Scheduler, then the well-known map, then what's // installed). $best = null; $best_len = 0; $matches = array(); foreach ( self::candidates() as $candidate ) { $prefix = $candidate['prefix']; $len = strlen( $prefix ); if ( $len < self::MIN_PREFIX || ! self::starts_with( $key, $prefix ) ) { continue; } $matches[] = $candidate; if ( $len > $best_len ) { $best = $candidate; $best_len = $len; } } if ( ! $best ) { return self::unknown(); } $confidence = ( rtrim( $best['prefix'], '_-' ) === $key || 'exact' === $best['confidence'] ) ? 'exact' : 'likely'; $state = $best['state']; // An inactive or missing owner is only believed when nothing that runs could own the name // too: another match that is active, WooCommerce (and its Action Scheduler) or core. if ( in_array( $state, array( 'inactive', 'not-installed' ), true ) ) { foreach ( $matches as $other ) { if ( $other['slug'] !== $best['slug'] && ( 'active' === $other['state'] || in_array( $other['type'], array( 'woocommerce', 'core' ), true ) ) ) { return self::owner( $best['type'], $best['slug'], $best['name'], 'unknown', 'unknown' ); } } } // Must-use plugins can load code without it being "installed": missing isn't provable. if ( 'not-installed' === $state && 'plugin' === $best['type'] && self::has_mu_plugins() ) { $state = 'unknown'; } return self::owner( $best['type'], $best['slug'], $best['name'], $state, $confidence ); } /** * Whether a name is the prefix itself or starts with it followed by a separator. Prefixes * that already end in `_` or `-` just need to start the name. * * @param string $key Name. * @param string $prefix Prefix. * @return bool */ private static function starts_with( string $key, string $prefix ): bool { if ( 0 !== strpos( $key, $prefix ) ) { return false; } $last = substr( $prefix, -1 ); if ( '_' === $last || '-' === $last || strlen( $key ) === strlen( $prefix ) ) { return true; } $next = $key[ strlen( $prefix ) ]; return '_' === $next || '-' === $next; } /** * Every prefix candidate, in tie-break order (ties keep the first one). * * @return array> */ private static function candidates(): array { if ( null !== self::$candidates ) { return self::$candidates; } self::watch(); $out = array( array( 'prefix' => 'apwc_', 'type' => 'plugin', 'slug' => 'admin-powerups-for-woocommerce', 'name' => 'Admin Powerups for WooCommerce', 'state' => 'active', 'confidence' => 'exact', ), ); $installed = self::installed(); $by_slug = array(); foreach ( $installed as $candidate ) { if ( 'plugin' === $candidate['type'] ) { $by_slug[ $candidate['slug'] ] = $candidate; } } // WooCommerce's code running is what counts (it can be loaded without being in active_plugins). $woo_state = defined( 'WC_VERSION' ) ? 'active' : ( isset( $by_slug['woocommerce'] ) ? $by_slug['woocommerce']['state'] : 'not-installed' ); foreach ( array( 'woocommerce_', 'woocommerce-', 'wc_', 'wc-', '_wc_', 'wcadmin', 'wc-admin', 'wc_admin', 'action_scheduler', 'action-scheduler', 'schema-actionscheduler_' ) as $prefix ) { $out[] = array( 'prefix' => $prefix, 'type' => 'woocommerce', 'slug' => 'woocommerce', 'name' => 'WooCommerce', 'state' => $woo_state, 'confidence' => 'likely', ); } foreach ( self::known_plugins() as $prefix => $plugin ) { $here = $by_slug[ $plugin[0] ] ?? null; $state = $here ? $here['state'] : 'not-installed'; if ( 'active' !== $state && self::loaded( $plugin[2] ) ) { $state = 'active'; // Its code runs (from a must-use loader, the host, or another folder). } elseif ( 'active' !== $state && ! empty( $plugin[3] ) && self::loaded( $plugin[3] ) ) { $state = 'unknown'; // Code that uses the same names runs (bundled package): not provably gone. } $out[] = array( 'prefix' => $prefix, 'type' => 'plugin', 'slug' => $plugin[0], 'name' => $here ? $here['name'] : $plugin[1], 'state' => $state, 'confidence' => 'likely', ); } foreach ( $installed as $candidate ) { $out[] = $candidate + array( 'confidence' => 'likely' ); } self::$candidates = $out; return $out; } /** * Installed plugins and themes as prefix candidates (cached for the request). * * @return array> */ private static function installed(): array { if ( null !== self::$installed ) { return self::$installed; } self::watch(); $out = array(); if ( ! function_exists( 'get_plugins' ) && defined( 'ABSPATH' ) && is_readable( ABSPATH . 'wp-admin/includes/plugin.php' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( null === self::$raw_plugins ) { self::$raw_plugins = function_exists( 'get_plugins' ) ? (array) get_plugins() : array(); } $plugins = self::$raw_plugins; foreach ( $plugins as $file => $data ) { $file = (string) $file; $slug = false !== strpos( $file, '/' ) ? dirname( $file ) : basename( $file, '.php' ); $state = self::plugin_active( $file ) || self::plugin_loaded( $file ) ? 'active' : 'inactive'; $name = is_array( $data ) && ! empty( $data['Name'] ) ? (string) $data['Name'] : $slug; $domain = is_array( $data ) && ! empty( $data['TextDomain'] ) ? (string) $data['TextDomain'] : ''; $type = 'woocommerce' === $slug ? 'woocommerce' : 'plugin'; foreach ( array_unique( array_filter( array( $slug, $domain ) ) ) as $base ) { foreach ( self::prefixes( $base ) as $prefix ) { $out[] = array( 'prefix' => $prefix, 'type' => $type, 'slug' => strtolower( $slug ), 'name' => $name, 'state' => $state, ); } } } // Must-use plugins and drop-ins always run. $always = array_merge( function_exists( 'get_mu_plugins' ) ? (array) get_mu_plugins() : array(), function_exists( 'get_dropins' ) ? (array) get_dropins() : array() ); self::$has_mu = function_exists( 'get_mu_plugins' ) && (bool) get_mu_plugins(); foreach ( $always as $file => $data ) { $file = (string) $file; $slug = false !== strpos( $file, '/' ) ? dirname( $file ) : basename( $file, '.php' ); $name = is_array( $data ) && ! empty( $data['Name'] ) ? (string) $data['Name'] : $slug; $domain = is_array( $data ) && ! empty( $data['TextDomain'] ) ? (string) $data['TextDomain'] : ''; foreach ( array_unique( array_filter( array( $slug, $domain ) ) ) as $base ) { foreach ( self::prefixes( $base ) as $prefix ) { $out[] = array( 'prefix' => $prefix, 'type' => 'plugin', 'slug' => strtolower( $slug ), 'name' => $name, 'state' => 'active', ); } } } $themes = function_exists( 'wp_get_themes' ) ? (array) wp_get_themes() : array(); $active = array( strtolower( (string) get_stylesheet() ), strtolower( (string) get_template() ) ); foreach ( $themes as $stylesheet => $theme ) { $slug = strtolower( (string) $stylesheet ); $name = $theme instanceof \WP_Theme ? (string) $theme->get( 'Name' ) : $slug; foreach ( self::prefixes( $slug ) as $prefix ) { $out[] = array( 'prefix' => $prefix, 'type' => 'theme', 'slug' => $slug, 'name' => '' !== $name ? $name : $slug, 'state' => in_array( $slug, $active, true ) ? 'active' : 'inactive', ); } } self::$installed = $out; return $out; } /** * Prefix spellings for a plugin or theme slug: as is, and with `-` as `_`. * * @param string $base Slug or text domain. * @return string[] */ private static function prefixes( string $base ): array { $base = strtolower( trim( $base ) ); if ( strlen( $base ) < self::MIN_PREFIX ) { return array(); } return array_values( array_unique( array( $base, str_replace( '-', '_', $base ) ) ) ); } /** * Whether a plugin's main file was loaded in this request although it isn't in active_plugins * (a must-use loader, Bedrock-style setups, some hosts): its code runs, so it counts as active. * * @param string $file Plugin file relative to the plugins folder. * @return bool */ private static function plugin_loaded( string $file ): bool { if ( null === self::$included ) { self::$included = array(); foreach ( get_included_files() as $path ) { self::$included[ wp_normalize_path( (string) $path ) ] = true; } } return defined( 'WP_PLUGIN_DIR' ) && isset( self::$included[ wp_normalize_path( WP_PLUGIN_DIR . '/' . $file ) ] ); } /** * Whether a plugin file is active on this site or network-wide. * * @param string $file Plugin file relative to the plugins folder. * @return bool */ private static function plugin_active( string $file ): bool { if ( function_exists( 'is_plugin_active' ) ) { return is_plugin_active( $file ); } return in_array( $file, (array) get_option( 'active_plugins', array() ), true ); } /** * Whether this site has must-use plugins. * * @return bool */ private static function has_mu_plugins(): bool { self::installed(); return self::$has_mu; } /** * Whether a well-known plugin's code is loaded: any of the constants or classes it defines * (classes checked without autoloading). * * @param string[] $checks Constant or class names. * @return bool */ private static function loaded( array $checks ): bool { foreach ( $checks as $check ) { if ( is_string( $check ) && '' !== $check && ( defined( $check ) || class_exists( $check, false ) ) ) { return true; } } return false; } /** * Build an answer. * * @param string $type Type. * @param string $slug Slug. * @param string $name Display name. * @param string $state State. * @param string $confidence Confidence. * @return array{type: string, slug: string, name: string, state: string, confidence: string} */ private static function owner( string $type, string $slug, string $name, string $state, string $confidence ): array { return array( 'type' => $type, 'slug' => $slug, 'name' => $name, 'state' => $state, 'confidence' => $confidence, ); } /** * Keep a (possibly filtered) answer to the documented shape and values. * * @param array $guess Answer. * @return array{type: string, slug: string, name: string, state: string, confidence: string} */ private static function normalize( array $guess ): array { $pick = static function ( $value, array $allowed ): string { return is_string( $value ) && in_array( $value, $allowed, true ) ? $value : 'unknown'; }; $text = static fn( $value ): string => Str::scalar( $value ); return array( 'type' => $pick( $guess['type'] ?? '', self::TYPES ), 'slug' => $text( $guess['slug'] ?? '' ), 'name' => $text( $guess['name'] ?? '' ), 'state' => $pick( $guess['state'] ?? '', self::STATES ), 'confidence' => $pick( $guess['confidence'] ?? '', self::CONFIDENCES ), ); } /** * Option name patterns that are always core. * * @param string $key Lowercased option name. * @return bool */ private static function is_core_pattern( string $key ): bool { global $wpdb; $roles = isset( $wpdb->prefix ) ? strtolower( $wpdb->prefix . 'user_roles' ) : ''; return ( '' !== $roles && $key === $roles ) || 0 === strpos( $key, 'widget_' ) || 0 === strpos( $key, 'auto_update_' ) || (bool) preg_match( '/^wp_[0-9]+_user_roles$/', $key ); } /** * Well-known plugins by prefix, for names left behind by plugins that are no longer installed. * Prefix => [ plugin folder, name, constants/classes that show its code is loaded, and * optionally constants/classes of code that shares its names (e.g. the Jetpack connection * package WooCommerce bundles): when one of those is loaded the owner is `unknown`, never * "not installed" ]. Generic short prefixes (`rocket`, `sbi`, `yoast`, `redirection`) are left * out: they collide with other plugins. * * @return array */ private static function known_plugins(): array { return array( 'woocommerce_stripe' => array( 'woocommerce-gateway-stripe', 'WooCommerce Stripe Gateway', array( 'WC_STRIPE_VERSION' ) ), 'wc_stripe' => array( 'woocommerce-gateway-stripe', 'WooCommerce Stripe Gateway', array( 'WC_STRIPE_VERSION' ) ), 'woocommerce-ppcp' => array( 'woocommerce-paypal-payments', 'WooCommerce PayPal Payments', array( 'PAYPAL_INTEGRATION_DATE' ) ), 'woocommerce_ppcp' => array( 'woocommerce-paypal-payments', 'WooCommerce PayPal Payments', array( 'PAYPAL_INTEGRATION_DATE' ) ), 'wcpay' => array( 'woocommerce-payments', 'WooPayments', array( 'WCPAY_VERSION_NUMBER' ) ), 'woocommerce_woocommerce_payments' => array( 'woocommerce-payments', 'WooPayments', array( 'WCPAY_VERSION_NUMBER' ) ), 'wc_facebook' => array( 'facebook-for-woocommerce', 'Facebook for WooCommerce', array( 'WC_Facebookcommerce' ) ), 'mailchimp_woocommerce' => array( 'mailchimp-for-woocommerce', 'Mailchimp for WooCommerce', array( 'MailChimp_WooCommerce_MailChimpApi' ) ), 'mailchimp-woocommerce' => array( 'mailchimp-for-woocommerce', 'Mailchimp for WooCommerce', array( 'MailChimp_WooCommerce_MailChimpApi' ) ), 'wpseo' => array( 'wordpress-seo', 'Yoast SEO', array( 'WPSEO_VERSION' ) ), 'rank_math' => array( 'seo-by-rank-math', 'Rank Math SEO', array( 'RANK_MATH_VERSION' ) ), 'rank-math' => array( 'seo-by-rank-math', 'Rank Math SEO', array( 'RANK_MATH_VERSION' ) ), 'aioseo' => array( 'all-in-one-seo-pack', 'All in One SEO', array( 'AIOSEO_VERSION' ) ), 'elementor' => array( 'elementor', 'Elementor', array( 'ELEMENTOR_VERSION' ) ), 'wpforms' => array( 'wpforms-lite', 'WPForms', array( 'WPFORMS_VERSION' ) ), 'wpcf7' => array( 'contact-form-7', 'Contact Form 7', array( 'WPCF7_VERSION' ) ), 'akismet' => array( 'akismet', 'Akismet', array( 'AKISMET_VERSION' ) ), 'wordfence' => array( 'wordfence', 'Wordfence Security', array( 'WORDFENCE_VERSION' ) ), 'wfls' => array( 'wordfence', 'Wordfence Security', array( 'WORDFENCE_VERSION' ) ), 'litespeed' => array( 'litespeed-cache', 'LiteSpeed Cache', array( 'LSCWP_V' ) ), 'wp_rocket' => array( 'wp-rocket', 'WP Rocket', array( 'WP_ROCKET_VERSION' ) ), 'w3tc' => array( 'w3-total-cache', 'W3 Total Cache', array( 'W3TC' ) ), 'autoptimize' => array( 'autoptimize', 'Autoptimize', array( 'AUTOPTIMIZE_PLUGIN_VERSION' ) ), 'wp-smush' => array( 'wp-smushit', 'Smush', array( 'WP_SMUSH_VERSION' ) ), 'wp_smush' => array( 'wp-smushit', 'Smush', array( 'WP_SMUSH_VERSION' ) ), 'updraft' => array( 'updraftplus', 'UpdraftPlus', array( 'UPDRAFTPLUS_DIR' ) ), 'wp_mail_smtp' => array( 'wp-mail-smtp', 'WP Mail SMTP', array( 'WPMS_PLUGIN_VER' ) ), 'mc4wp' => array( 'mailchimp-for-wp', 'MC4WP: Mailchimp for WordPress', array( 'MC4WP_VERSION' ) ), 'woocommerce_gzd' => array( 'woocommerce-germanized', 'Germanized for WooCommerce', array( 'WooCommerce_Germanized' ) ), 'wc_gzd' => array( 'woocommerce-germanized', 'Germanized for WooCommerce', array( 'WooCommerce_Germanized' ) ), 'redirection_options' => array( 'redirection', 'Redirection', array( 'REDIRECTION_VERSION' ) ), 'sb_instagram' => array( 'instagram-feed', 'Smash Balloon Instagram Feed', array( 'SBIVER', 'SBI_PLUGIN_DIR' ) ), 'jetpack' => array( 'jetpack', 'Jetpack', array( 'JETPACK__VERSION' ), array( 'Automattic\\Jetpack\\Connection\\Manager', 'Automattic\\Jetpack\\Connection\\Client', 'Automattic\\Jetpack\\Config' ) ), ); } /** * WordPress core cron hooks: every hook core schedules in wp-includes and wp-admin (WordPress * 7.1.2, `wp_schedule_event()` / `wp_schedule_single_event()`), plus `wp_https_detection` * from older versions. The one list the Cron inspector and owner guesses use. */ const CORE_CRON_HOOKS = array( 'wp_version_check', 'wp_update_plugins', 'wp_update_themes', 'wp_scheduled_delete', 'wp_scheduled_auto_draft_delete', 'delete_expired_transients', 'wp_privacy_delete_old_export_files', 'wp_privacy_personal_data_cleanup_requests', 'recovery_mode_clean_expired_keys', 'wp_site_health_scheduled_check', 'wp_https_detection', 'wp_update_user_counts', 'wp_delete_temp_updater_backups', 'publish_future_post', 'update_network_counts', 'wp_maybe_auto_update', 'do_pings', 'wp_split_shared_term_batch', 'wp_update_comment_type_batch', 'importer_scheduled_cleanup', 'upgrader_scheduled_cleanup', ); /** * WordPress core cron hooks (CORE_CRON_HOOKS). * * @since 1.0.0 * @return string[] */ public static function core_cron_hooks(): array { return self::CORE_CRON_HOOKS; } /** * Option names WordPress core creates or uses: src/Support/core-options.php, generated by * bin/generate-core-options.php from populate_options() (WordPress 6.7 to 7.1) plus core * options it doesn't create. Exact names, as stored (some have capitals, e.g. `WPLANG`). * * @since 1.0.0 * @return string[] */ public static function core_option_names(): array { if ( null === self::$core_options ) { $file = __DIR__ . '/core-options.php'; $list = is_readable( $file ) ? require $file : array(); self::$core_options = array_values( array_filter( (array) $list, 'is_string' ) ); } return self::$core_options; } /** * Lowercased core names for a context as a lookup hash (built once per request). * * @param string $context `option` or `cron_hook`. * @return array */ private static function core_hash( string $context ): array { if ( ! isset( self::$core_hash[ $context ] ) ) { $names = 'cron_hook' === $context ? self::core_cron_hooks() : self::core_option_names(); self::$core_hash[ $context ] = array_flip( array_map( 'strtolower', $names ) ); } return self::$core_hash[ $context ]; } }