| 1 |
<?php |
| 2 |
/** |
| 3 |
* Connection status reporter. |
| 4 |
* |
| 5 |
* @package ThinkRank\Diagnostics |
| 6 |
*/ |
| 7 |
|
| 8 |
declare(strict_types=1); |
| 9 |
|
| 10 |
namespace ThinkRank\Diagnostics; |
| 11 |
|
| 12 |
use ThinkRank\Core\Capability_Manager; |
| 13 |
use ThinkRank\Core\Plan_Config; |
| 14 |
use ThinkRank\Database\Database_Schema; |
| 15 |
use ThinkRank\Mcp\Mcp_Manager; |
| 16 |
|
| 17 |
if ( ! defined( 'ABSPATH' ) ) { |
| 18 |
exit; // Exit if accessed directly. |
| 19 |
} |
| 20 |
|
| 21 |
/** |
| 22 |
* Builds a read-only integration health report for ThinkRank. |
| 23 |
* |
| 24 |
* A single source of truth consumed by the `thinkrank/get-connection-status` |
| 25 |
* ability and the `GET /thinkrank/v1/connection-status` REST route. It reports |
| 26 |
* whatever an external MCP/Abilities client needs to distinguish a certificate |
| 27 |
* problem from an authentication problem from a permission problem from an |
| 28 |
* ability-registration problem — without reading plugin source or DB tables by |
| 29 |
* hand (see #188). |
| 30 |
* |
| 31 |
* The report is side-effect free: it makes no outbound HTTP request and never |
| 32 |
* returns secret material (API keys, OAuth tokens, connection tokens). Actively |
| 33 |
* exercising the round trip (a live "Test Connection") belongs to the |
| 34 |
* integration screen tracked in #189. |
| 35 |
*/ |
| 36 |
class Connection_Status { |
| 37 |
|
| 38 |
/** |
| 39 |
* Build the full connection-status report. |
| 40 |
* |
| 41 |
* @return array<string, mixed> |
| 42 |
*/ |
| 43 |
public static function report(): array { |
| 44 |
return [ |
| 45 |
'plugin' => self::plugin_info(), |
| 46 |
'abilities' => self::abilities_info(), |
| 47 |
'mcp_server' => self::mcp_info(), |
| 48 |
'user' => self::user_info(), |
| 49 |
'urls' => self::analyze_schemes( home_url(), site_url(), rest_url() ), |
| 50 |
'database' => self::database_info(), |
| 51 |
'scoring' => self::scoring_info(), |
| 52 |
]; |
| 53 |
} |
| 54 |
|
| 55 |
/** |
| 56 |
* ThinkRank + ThinkRank Pro versions and whether Pro is active. |
| 57 |
* |
| 58 |
* @return array<string, mixed> |
| 59 |
*/ |
| 60 |
private static function plugin_info(): array { |
| 61 |
return [ |
| 62 |
'thinkrank_version' => defined( 'THINKRANK_VERSION' ) ? THINKRANK_VERSION : null, |
| 63 |
'pro_active' => Plan_Config::is_pro(), |
| 64 |
'pro_version' => defined( 'THINKRANK_PRO_VERSION' ) ? THINKRANK_PRO_VERSION : null, |
| 65 |
]; |
| 66 |
} |
| 67 |
|
| 68 |
/** |
| 69 |
* Abilities API availability + which ThinkRank abilities are registered. |
| 70 |
* |
| 71 |
* @return array<string, mixed> |
| 72 |
*/ |
| 73 |
private static function abilities_info(): array { |
| 74 |
$api_available = function_exists( 'wp_register_ability' ) && function_exists( 'wp_get_abilities' ); |
| 75 |
$names = []; |
| 76 |
|
| 77 |
if ( $api_available ) { |
| 78 |
foreach ( wp_get_abilities() as $ability ) { |
| 79 |
if ( ! is_object( $ability ) || ! method_exists( $ability, 'get_name' ) ) { |
| 80 |
continue; |
| 81 |
} |
| 82 |
$name = (string) $ability->get_name(); |
| 83 |
if ( 0 === strpos( $name, 'thinkrank/' ) || 0 === strpos( $name, 'thinkrank-pro/' ) ) { |
| 84 |
$names[] = $name; |
| 85 |
} |
| 86 |
} |
| 87 |
sort( $names ); |
| 88 |
} |
| 89 |
|
| 90 |
return [ |
| 91 |
'api_available' => $api_available, |
| 92 |
'registered' => count( $names ) > 0, |
| 93 |
'thinkrank_count' => count( $names ), |
| 94 |
'names' => $names, |
| 95 |
]; |
| 96 |
} |
| 97 |
|
| 98 |
/** |
| 99 |
* MCP server toggle + the endpoints a client would connect to. |
| 100 |
* |
| 101 |
* @return array<string, mixed> |
| 102 |
*/ |
| 103 |
private static function mcp_info(): array { |
| 104 |
return [ |
| 105 |
'enabled' => class_exists( Mcp_Manager::class ) ? Mcp_Manager::is_enabled() : false, |
| 106 |
'endpoint' => home_url( '/thinkrank/mcp' ), |
| 107 |
'rest_fallback' => rest_url( 'thinkrank/v1/mcp' ), |
| 108 |
]; |
| 109 |
} |
| 110 |
|
| 111 |
/** |
| 112 |
* Current user + the ThinkRank capabilities they hold. |
| 113 |
* |
| 114 |
* @return array<string, mixed> |
| 115 |
*/ |
| 116 |
private static function user_info(): array { |
| 117 |
$user = wp_get_current_user(); |
| 118 |
|
| 119 |
return [ |
| 120 |
'id' => get_current_user_id(), |
| 121 |
'login' => ( $user && $user->exists() ) ? $user->user_login : '', |
| 122 |
'is_admin' => current_user_can( 'manage_options' ), |
| 123 |
'thinkrank_capabilities' => class_exists( Capability_Manager::class ) |
| 124 |
? Capability_Manager::user_capabilities() |
| 125 |
: [], |
| 126 |
]; |
| 127 |
} |
| 128 |
|
| 129 |
/** |
| 130 |
* ThinkRank custom-table status: version, whether an update is pending, |
| 131 |
* and per-table existence. |
| 132 |
* |
| 133 |
* @return array<string, mixed> |
| 134 |
*/ |
| 135 |
private static function database_info(): array { |
| 136 |
if ( ! class_exists( Database_Schema::class ) ) { |
| 137 |
return [ |
| 138 |
'installed_version' => null, |
| 139 |
'expected_version' => null, |
| 140 |
'up_to_date' => false, |
| 141 |
'tables' => [], |
| 142 |
]; |
| 143 |
} |
| 144 |
|
| 145 |
$schema = new Database_Schema(); |
| 146 |
$status = $schema->get_database_status(); |
| 147 |
|
| 148 |
$tables = []; |
| 149 |
foreach ( (array) ( $status['tables'] ?? [] ) as $name => $info ) { |
| 150 |
$tables[ $name ] = ! empty( $info['exists'] ); |
| 151 |
} |
| 152 |
|
| 153 |
return [ |
| 154 |
'installed_version' => $status['version'] ?? null, |
| 155 |
'expected_version' => $schema->get_schema_version(), |
| 156 |
'up_to_date' => empty( $status['needs_update'] ), |
| 157 |
'tables' => $tables, |
| 158 |
]; |
| 159 |
} |
| 160 |
|
| 161 |
/** |
| 162 |
* Whether scoring (and saved-score storage) is available: the scoring |
| 163 |
* ability is registered and the analysis table exists. |
| 164 |
* |
| 165 |
* @return array<string, bool> |
| 166 |
*/ |
| 167 |
private static function scoring_info(): array { |
| 168 |
$ability_available = function_exists( 'wp_has_ability' ) |
| 169 |
? (bool) wp_has_ability( 'thinkrank/get-seo-score' ) |
| 170 |
: false; |
| 171 |
|
| 172 |
$table_exists = false; |
| 173 |
if ( class_exists( Database_Schema::class ) ) { |
| 174 |
$status = ( new Database_Schema() )->get_database_status(); |
| 175 |
$table_exists = ! empty( $status['tables']['seo_analysis']['exists'] ); |
| 176 |
} |
| 177 |
|
| 178 |
return [ |
| 179 |
'ability_available' => $ability_available, |
| 180 |
'analysis_table_exists' => $table_exists, |
| 181 |
]; |
| 182 |
} |
| 183 |
|
| 184 |
/** |
| 185 |
* Compare the URL schemes WordPress uses. A `home`/`siteurl` scheme |
| 186 |
* mismatch (or a REST URL whose scheme differs from `home`) is the classic |
| 187 |
* cause of an MCP client's requests bouncing between HTTP and HTTPS. |
| 188 |
* |
| 189 |
* Pure function of its inputs (native `parse_url` only) so it is unit |
| 190 |
* testable without a WordPress runtime. |
| 191 |
* |
| 192 |
* @param string $home_url The `home` URL. |
| 193 |
* @param string $site_url The `siteurl` URL. |
| 194 |
* @param string $rest_url The REST API base URL. |
| 195 |
* @return array<string, mixed> |
| 196 |
*/ |
| 197 |
public static function analyze_schemes( string $home_url, string $site_url, string $rest_url ): array { |
| 198 |
$home = strtolower( (string) wp_parse_url( $home_url, PHP_URL_SCHEME ) ); |
| 199 |
$site = strtolower( (string) wp_parse_url( $site_url, PHP_URL_SCHEME ) ); |
| 200 |
$rest = strtolower( (string) wp_parse_url( $rest_url, PHP_URL_SCHEME ) ); |
| 201 |
|
| 202 |
return [ |
| 203 |
'home' => $home_url, |
| 204 |
'siteurl' => $site_url, |
| 205 |
'rest_url' => $rest_url, |
| 206 |
'home_scheme' => $home, |
| 207 |
'siteurl_scheme' => $site, |
| 208 |
'rest_scheme' => $rest, |
| 209 |
'is_https' => 'https' === $home, |
| 210 |
'home_siteurl_match' => '' !== $home && $home === $site, |
| 211 |
'rest_matches_home' => '' !== $rest && $rest === $home, |
| 212 |
]; |
| 213 |
} |
| 214 |
} |
| 215 |
|