, failed: array, file: string} Ownership state and evidence. */ public static function detect() { $result = array( 'state' => self::OWNER_AIVE, 'owners' => array(), 'failed' => array(), 'file' => self::physical_file_path(), ); if ( ! self::site_is_public() ) { $result['state'] = self::OWNER_PRIVATE_SITE; return $result; } if ( self::physical_file_exists() ) { $result['state'] = self::OWNER_PHYSICAL_FILE; return $result; } foreach ( self::named_plugins() as $plugin ) { if ( ! self::plugin_is_active( $plugin ) ) { continue; } $evidence = self::inspect( $plugin ); if ( self::EVIDENCE_YES === $evidence['outcome'] ) { $result['owners'][] = array( 'slug' => $plugin['slug'], 'name' => $plugin['name'], 'setting' => $plugin['setting'], 'action' => $plugin['action'], ); continue; } if ( self::EVIDENCE_FAILED === $evidence['outcome'] ) { $result['failed'][] = array( 'slug' => $plugin['slug'], 'name' => $plugin['name'], 'setting' => $plugin['setting'], 'reason' => $evidence['reason'], ); } } if ( count( $result['owners'] ) > 1 ) { $result['state'] = self::OWNER_MULTIPLE; return $result; } if ( 1 === count( $result['owners'] ) ) { $result['state'] = self::OWNER_PLUGIN; return $result; } if ( array() !== $result['failed'] ) { $result['state'] = self::OWNER_UNKNOWN; } return $result; } /** * Reports whether AIVE may contribute to the response. * * @since 1.2.0 * * @param array{state: string} $ownership Result of detect(). * @return bool True only when AIVE owns its managed block. */ public static function allows_injection( array $ownership ) { return isset( $ownership['state'] ) && self::OWNER_AIVE === $ownership['state']; } /** * Reports whether WordPress is asking search engines to index the site. * * The cast is the one `do_robots()` itself makes, down to the behaviour on * a site with no `blog_public` row at all, so that AIVE's notion of a * discouraged site is the same one WordPress acts on rather than a second * opinion about it. * * @since 1.2.0 * * @return bool True when `blog_public` is on. */ public static function site_is_public() { return (bool) get_option( 'blog_public' ); } /** * Returns the path a physical robots.txt would occupy. * * @since 1.2.0 * * @return string Absolute path, or an empty string when ABSPATH is absent. */ public static function physical_file_path() { if ( ! defined( 'ABSPATH' ) ) { return ''; } return trailingslashit( ABSPATH ) . 'robots.txt'; } /** * Reports whether a physical robots.txt sits in the WordPress root. * * @since 1.2.0 * * @return bool True when the file exists. */ public static function physical_file_exists() { $path = self::physical_file_path(); return '' !== $path && file_exists( $path ); } /** * Returns the named plugins this contract recognises as robots owners. * * The list is closed deliberately. Each entry names one stored setting that * the plugin's own robots editor writes, and that setting is the only thing * that counts as evidence. Extending ownership to another SEO plugin means * identifying its equivalent setting and is a scope decision, not a * configuration one - which is why there is no filter here. * * Yoast SEO is absent on purpose: it writes robots.txt by creating a * physical file, which rule 2 of the precedence already covers, so it needs * no setting-level check and must not be labelled an owner merely for being * active. * * `action` is phrased as a destination inside the owning plugin rather than * rendered as a link. These screens move between plugin versions, and a * confident link to a page that 404s is worse guidance than a named path. * * @since 1.2.0 * * @return array, constants: array, option: string, setting: string, action: string, check: string}> Plugin definitions. */ private static function named_plugins() { return array( array( 'slug' => 'rank-math', 'name' => 'Rank Math SEO', 'files' => array( 'seo-by-rank-math/rank-math.php', 'seo-by-rank-math-pro/rank-math-pro.php' ), 'constants' => array( 'RANK_MATH_VERSION', 'RANK_MATH_PRO_VERSION' ), 'option' => 'rank-math-options-general', 'setting' => 'rank-math-options-general[robots_txt_content]', 'action' => 'Rank Math > General Settings > Edit robots.txt', 'check' => 'inspect_rank_math', ), array( 'slug' => 'aioseo', 'name' => 'All in One SEO', 'files' => array( 'all-in-one-seo-pack/all_in_one_seo_pack.php', 'all-in-one-seo-pack-pro/all_in_one_seo_pack.php' ), 'constants' => array( 'AIOSEO_VERSION' ), 'option' => 'aioseo_options', 'setting' => 'aioseo_options tools.robots.enable', 'action' => 'All in One SEO > Tools > Robots.txt Editor', 'check' => 'inspect_aioseo', ), array( 'slug' => 'seopress-pro', 'name' => 'SEOPress Pro', 'files' => array( 'wp-seopress-pro/seopress-pro.php' ), 'constants' => array( 'SEOPRESS_PRO_VERSION' ), 'option' => 'seopress_pro_option_name', 'setting' => 'seopress_pro_option_name[seopress_robots_enable]', 'action' => 'SEOPress Pro > Robots.txt', 'check' => 'inspect_seopress_pro', ), ); } /** * Reports whether a named plugin is active on this site. * * Deliberately does not use `is_plugin_active()`: that function lives in * wp-admin and is not loaded on a front-end request, which is exactly where * the `robots_txt` filter runs. The stored option is the same source * `is_plugin_active()` reads, and a defined constant covers a plugin loaded * by some other means, such as a must-use loader. * * @since 1.2.0 * * @param array{files: array, constants: array} $plugin Plugin definition. * @return bool True when the plugin appears to be running. */ private static function plugin_is_active( array $plugin ) { foreach ( $plugin['constants'] as $constant ) { if ( defined( $constant ) ) { return true; } } $active = get_option( 'active_plugins', array() ); $active = is_array( $active ) ? $active : array(); $network = is_multisite() ? get_site_option( 'active_sitewide_plugins', array() ) : array(); $network = is_array( $network ) ? $network : array(); foreach ( $plugin['files'] as $file ) { if ( in_array( $file, $active, true ) || isset( $network[ $file ] ) ) { return true; } } return false; } /** * Runs one plugin's evidence check, converting a fault into a failed check. * * A third-party option can hold anything, and reading it must not be able to * take down a robots.txt response. A Throwable here therefore becomes the * same `failed` outcome as an undecodable value: AIVE does not know, says * so, and writes nothing. * * @since 1.2.0 * * @param array{check: string, option: string} $plugin Plugin definition. * @return array{outcome: string, reason: string} Evidence outcome and, when failed, why. */ private static function inspect( array $plugin ) { try { $stored = get_option( $plugin['option'], null ); switch ( $plugin['check'] ) { case 'inspect_rank_math': return self::inspect_rank_math( $stored ); case 'inspect_aioseo': return self::inspect_aioseo( $stored ); case 'inspect_seopress_pro': return self::inspect_seopress_pro( $stored ); } return self::failed( 'the evidence check is not available' ); } catch ( Throwable $error ) { return self::failed( 'inspecting the setting raised an error' ); } } /** * Evidence check for Rank Math: a non-empty custom robots.txt body. * * Rank Math stores the robots.txt its editor serves as the body text * itself, so a non-empty body is the switch: with no body it serves nothing * and does not own the response. * * @since 1.2.0 * * @param mixed $option Stored option value. * @return array{outcome: string, reason: string} Evidence outcome. */ private static function inspect_rank_math( $option ) { $decoded = self::as_array( $option ); if ( 'missing' === $decoded['state'] ) { return self::not_evidenced(); } if ( 'failed' === $decoded['state'] ) { return self::failed( 'the stored option is not a readable settings array' ); } if ( ! array_key_exists( 'robots_txt_content', $decoded['value'] ) ) { return self::not_evidenced(); } $content = $decoded['value']['robots_txt_content']; if ( ! is_scalar( $content ) ) { return self::failed( 'robots_txt_content is not text' ); } return '' === trim( (string) $content ) ? self::not_evidenced() : self::evidenced(); } /** * Evidence check for All in One SEO: the robots editor switched on. * * AIOSEO stores its settings as a JSON document in one option, so the value * is decoded before `tools.robots.enable` is read. * * @since 1.2.0 * * @param mixed $option Stored option value. * @return array{outcome: string, reason: string} Evidence outcome. */ private static function inspect_aioseo( $option ) { $decoded = self::as_array( $option ); if ( 'missing' === $decoded['state'] ) { return self::not_evidenced(); } if ( 'failed' === $decoded['state'] ) { return self::failed( 'the stored option could not be decoded as JSON' ); } $tools = $decoded['value']; foreach ( array( 'tools', 'robots' ) as $key ) { if ( ! is_array( $tools ) || ! array_key_exists( $key, $tools ) ) { return self::not_evidenced(); } $tools = $tools[ $key ]; } if ( ! is_array( $tools ) || ! array_key_exists( 'enable', $tools ) ) { return self::not_evidenced(); } return self::from_flag( $tools['enable'] ); } /** * Evidence check for SEOPress Pro: its robots.txt feature switched on. * * @since 1.2.0 * * @param mixed $option Stored option value. * @return array{outcome: string, reason: string} Evidence outcome. */ private static function inspect_seopress_pro( $option ) { $decoded = self::as_array( $option ); if ( 'missing' === $decoded['state'] ) { return self::not_evidenced(); } if ( 'failed' === $decoded['state'] ) { return self::failed( 'the stored option is not a readable settings array' ); } if ( ! array_key_exists( 'seopress_robots_enable', $decoded['value'] ) ) { return self::not_evidenced(); } return self::from_flag( $decoded['value']['seopress_robots_enable'] ); } /** * Reduces a stored option to an array, reporting what it could not read. * * Three shapes are accepted because all three occur in the wild: an array * from a normal `update_option()`, a JSON document as AIOSEO stores, and a * serialised string left by an older release or restored by a migration * tool that wrote the raw column. * * @since 1.2.0 * * @param mixed $option Stored option value. * @return array{state: string, value: array} Reduced value and whether it was readable. */ private static function as_array( $option ) { $missing = array( 'state' => 'missing', 'value' => array(), ); if ( null === $option || false === $option || '' === $option || array() === $option ) { return $missing; } if ( is_array( $option ) ) { return array( 'state' => 'ok', 'value' => $option, ); } if ( ! is_string( $option ) ) { return array( 'state' => 'failed', 'value' => array(), ); } $json = json_decode( $option, true ); if ( is_array( $json ) ) { return array( 'state' => 'ok', 'value' => $json, ); } $unserialized = maybe_unserialize( $option ); if ( is_array( $unserialized ) ) { return array( 'state' => 'ok', 'value' => $unserialized, ); } return array( 'state' => 'failed', 'value' => array(), ); } /** * Reads an enable flag whose stored type varies between plugins. * * A value whose shape cannot establish enablement one way or the other is a * failed check rather than a quiet "off": treating it as off would resume * injection on a site whose owner may well have the other plugin writing * robots.txt. * * @since 1.2.0 * * @param mixed $value Stored flag value. * @return array{outcome: string, reason: string} Evidence outcome. */ private static function from_flag( $value ) { if ( is_bool( $value ) ) { return $value ? self::evidenced() : self::not_evidenced(); } if ( is_int( $value ) || is_float( $value ) ) { return 0 === (int) $value ? self::not_evidenced() : self::evidenced(); } if ( is_string( $value ) ) { $normalized = strtolower( trim( $value ) ); if ( in_array( $normalized, array( '1', 'true', 'yes', 'on', 'enabled' ), true ) ) { return self::evidenced(); } if ( in_array( $normalized, array( '', '0', 'false', 'no', 'off', 'disabled' ), true ) ) { return self::not_evidenced(); } return self::failed( 'the setting holds an unrecognised value' ); } if ( null === $value ) { return self::not_evidenced(); } return self::failed( 'the setting holds an unexpected value shape' ); } /** * Builds an evidenced outcome. * * @since 1.2.0 * * @return array{outcome: string, reason: string} Evidence outcome. */ private static function evidenced() { return array( 'outcome' => self::EVIDENCE_YES, 'reason' => '', ); } /** * Builds a not-evidenced outcome. * * @since 1.2.0 * * @return array{outcome: string, reason: string} Evidence outcome. */ private static function not_evidenced() { return array( 'outcome' => self::EVIDENCE_NO, 'reason' => '', ); } /** * Builds a failed-check outcome. * * @since 1.2.0 * * @param string $reason Why the check could not be completed. * @return array{outcome: string, reason: string} Evidence outcome. */ private static function failed( $reason ) { return array( 'outcome' => self::EVIDENCE_FAILED, 'reason' => (string) $reason, ); } }