'GET', 'callback' => [$this, 'get_capabilities'], 'permission_callback' => [$this, 'check_basic_permissions'], ]); register_rest_route(self::NAMESPACE, '/plugin-info', [ 'methods' => 'GET', 'callback' => [$this, 'get_plugin_info'], 'permission_callback' => [$this, 'check_basic_permissions'], ]); register_rest_route(self::NAMESPACE, '/system-status', [ 'methods' => 'GET', 'callback' => [$this, 'get_system_status'], 'permission_callback' => [$this, 'check_basic_permissions'], ]); // Integration health check for MCP/Abilities clients (see #188). This // route is intentionally gated only by the admin capability, NOT by the // `enable_mcp` toggle, so it stays reachable as a diagnostic even when // the MCP server is off or abilities failed to register. register_rest_route(self::NAMESPACE, '/connection-status', [ 'methods' => 'GET', 'callback' => [$this, 'get_connection_status'], 'permission_callback' => [$this, 'check_admin_permissions'], ]); // Settings endpoints register_rest_route(self::NAMESPACE, '/settings', [ 'methods' => 'GET', 'callback' => [$this, 'get_settings'], 'permission_callback' => [$this, 'check_settings_permissions'], ]); register_rest_route(self::NAMESPACE, '/settings', [ 'methods' => 'POST', 'callback' => [$this, 'save_settings'], 'permission_callback' => [$this, 'check_settings_permissions'], 'args' => [ 'ai_provider' => [ 'type' => 'string', // Includes '' (Settings::AI_PROVIDER_NONE) so a client can // clear the selection, not just switch between providers. 'enum' => \ThinkRank\Core\Settings::selectable_ai_providers(), 'sanitize_callback' => 'sanitize_key', // The enum is inert without this: has_valid_params() skips // an arg entirely unless a validate_callback is set (#394). 'validate_callback' => 'rest_validate_request_arg', ], 'openai_api_key' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'openai_model' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'claude_api_key' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'claude_model' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'gemini_api_key' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'gemini_model' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'openrouter_api_key' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'openrouter_model' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'max_tokens' => [ 'type' => 'integer', 'minimum' => 1, 'maximum' => 32000, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ], 'temperature' => [ 'type' => 'number', 'minimum' => 0, 'maximum' => 2, 'validate_callback' => 'rest_validate_request_arg', ], 'cache_duration' => [ 'type' => 'integer', 'minimum' => 0, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ], 'keep_data_on_uninstall' => [ 'type' => 'boolean', 'sanitize_callback' => 'rest_sanitize_boolean', ], 'enable_mcp' => [ 'type' => 'boolean', 'sanitize_callback' => 'rest_sanitize_boolean', ], 'enable_migration_tools' => [ 'type' => 'boolean', 'sanitize_callback' => 'rest_sanitize_boolean', ], 'enable_import_export' => [ 'type' => 'boolean', 'sanitize_callback' => 'rest_sanitize_boolean', ], ], ]); // Metadata endpoints register_rest_route(self::NAMESPACE, '/metadata/(?P\d+)', [ 'methods' => 'GET', 'callback' => [$this, 'get_metadata'], 'permission_callback' => [$this, 'check_basic_permissions'], 'args' => [ 'post_id' => [ 'type' => 'integer', 'required' => true, ], ], ]); // AI endpoints register_rest_route(self::NAMESPACE, '/ai/generate-metadata', [ 'methods' => 'POST', 'callback' => [$this, 'generate_ai_metadata'], 'permission_callback' => [$this, 'check_basic_permissions'], 'args' => [ 'content' => [ 'type' => 'string', 'required' => true, 'sanitize_callback' => [$this, 'sanitize_ai_content'], ], 'target_keyword' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'content_type' => [ 'type' => 'string', 'default' => 'blog_post', 'sanitize_callback' => 'sanitize_text_field', ], 'tone' => [ 'type' => 'string', 'default' => 'professional', 'sanitize_callback' => 'sanitize_text_field', ], 'post_id' => [ 'type' => 'integer', 'required' => false, 'default' => 0, 'sanitize_callback' => 'absint', ], ], ]); register_rest_route(self::NAMESPACE, '/ai/improve-title', [ 'methods' => 'POST', 'callback' => [$this, 'improve_ai_title'], 'permission_callback' => [$this, 'check_basic_permissions'], 'args' => [ 'content' => [ 'type' => 'string', 'required' => true, 'sanitize_callback' => [$this, 'sanitize_ai_content'], ], 'current_title' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'target_keyword' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'content_type' => [ 'type' => 'string', 'default' => 'blog_post', 'sanitize_callback' => 'sanitize_text_field', ], 'tone' => [ 'type' => 'string', 'default' => 'professional', 'sanitize_callback' => 'sanitize_text_field', ], 'suggestion' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'post_id' => [ 'type' => 'integer', 'required' => false, 'default' => 0, 'sanitize_callback' => 'absint', ], ], ]); register_rest_route(self::NAMESPACE, '/ai/improve-meta-description', [ 'methods' => 'POST', 'callback' => [$this, 'improve_ai_meta_description'], 'permission_callback' => [$this, 'check_basic_permissions'], 'args' => [ 'content' => [ 'type' => 'string', 'required' => true, 'sanitize_callback' => [$this, 'sanitize_ai_content'], ], 'current_description' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_textarea_field', ], 'target_keyword' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'content_type' => [ 'type' => 'string', 'default' => 'blog_post', 'sanitize_callback' => 'sanitize_text_field', ], 'tone' => [ 'type' => 'string', 'default' => 'professional', 'sanitize_callback' => 'sanitize_text_field', ], 'suggestion' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'post_id' => [ 'type' => 'integer', 'required' => false, 'default' => 0, 'sanitize_callback' => 'absint', ], ], ]); register_rest_route(self::NAMESPACE, '/ai/explain-suggestion', [ 'methods' => 'POST', 'callback' => [$this, 'explain_ai_suggestion'], 'permission_callback' => [$this, 'check_basic_permissions'], 'args' => [ 'content' => [ 'type' => 'string', 'required' => true, 'sanitize_callback' => [$this, 'sanitize_ai_content'], ], 'suggestion' => [ 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ], 'title' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'target_keyword' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'content_type' => [ 'type' => 'string', 'default' => 'blog_post', 'sanitize_callback' => 'sanitize_text_field', ], ], ]); register_rest_route(self::NAMESPACE, '/ai/add-dofollow-link', [ 'methods' => 'POST', 'callback' => [$this, 'add_ai_dofollow_link'], 'permission_callback' => [$this, 'check_basic_permissions'], 'args' => [ 'content' => [ 'type' => 'string', 'required' => true, 'sanitize_callback' => [$this, 'sanitize_ai_content'], ], 'target_keyword' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'content_type' => [ 'type' => 'string', 'default' => 'blog_post', 'sanitize_callback' => 'sanitize_text_field', ], ], ]); register_rest_route(self::NAMESPACE, '/ai/add-keyword-paragraph', [ 'methods' => 'POST', 'callback' => [$this, 'add_ai_keyword_paragraph'], 'permission_callback' => [$this, 'check_basic_permissions'], 'args' => [ 'content' => [ 'type' => 'string', 'required' => true, 'sanitize_callback' => [$this, 'sanitize_ai_content'], ], 'target_keyword' => [ 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ], 'content_type' => [ 'type' => 'string', 'default' => 'blog_post', 'sanitize_callback' => 'sanitize_text_field', ], 'tone' => [ 'type' => 'string', 'default' => 'professional', 'sanitize_callback' => 'sanitize_text_field', ], 'word_count' => [ 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', ], 'keyword_count' => [ 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', ], ], ]); register_rest_route(self::NAMESPACE, '/schema/enable-for-post', [ 'methods' => 'POST', 'callback' => [$this, 'enable_schema_for_post'], 'permission_callback' => [$this, 'check_admin_permissions'], 'args' => [ 'post_id' => [ 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint', ], ], ]); register_rest_route(self::NAMESPACE, '/ai/test-connection', [ 'methods' => 'POST', 'callback' => [$this, 'test_ai_connection'], 'permission_callback' => [$this, 'check_admin_permissions'], 'args' => [ 'api_key' => [ 'type' => 'string', 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ], 'provider' => [ 'type' => 'string', 'required' => false, 'default' => 'openai', 'sanitize_callback' => 'sanitize_key', ], 'model' => [ 'type' => 'string', 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ], ], ]); register_rest_route(self::NAMESPACE, '/ai/providers', [ 'methods' => 'GET', 'callback' => [$this, 'get_ai_providers'], 'permission_callback' => [$this, 'check_basic_permissions'], ]); // Register content brief endpoints $content_brief_endpoint = new \ThinkRank\API\Content_Brief_Endpoint(); $content_brief_endpoint->register_routes(); // Register SEO score endpoints try { $database = new \ThinkRank\Core\Database(); $seo_calculator = new \ThinkRank\AI\SEOScoreCalculator($database); $seo_score_endpoint = new \ThinkRank\API\SEOScoreEndpoint($seo_calculator); $seo_score_endpoint->register_routes(); } catch (\Exception $e) { // SEO Score endpoint registration failed } // Register Usage Analytics endpoints try { $usage_analytics_endpoint = new \ThinkRank\API\Usage_Analytics_Endpoint(); $usage_analytics_endpoint->register_routes(); } catch (\Exception $e) { // Usage Analytics endpoint registration failed } // Register Site SEO Analyzer endpoint try { $seo_analyzer_endpoint = new \ThinkRank\API\SEO_Analyzer_Endpoint(); $seo_analyzer_endpoint->register_routes(); } catch (\Exception $e) { // Site SEO Analyzer endpoint registration failed } // Register SEO Analytics endpoints try { $seo_analytics_endpoint = new \ThinkRank\API\SEO_Analytics_Endpoint(); $seo_analytics_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register SEO Analytics endpoint } // Register Instant Indexing endpoints try { $instant_indexing_endpoint = new \ThinkRank\API\Instant_Indexing_Endpoint(); $instant_indexing_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register Instant Indexing endpoint } // Register Pillar Content endpoints try { $pillar_content_endpoint = new \ThinkRank\API\Pillar_Content_Endpoint(); $pillar_content_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register Pillar Content endpoint } // Register Focus Keyword Usage endpoint ("already used" status). try { $focus_keyword_usage_endpoint = new \ThinkRank\API\Focus_Keyword_Usage_Endpoint(); $focus_keyword_usage_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register Focus Keyword Usage endpoint } // Register Global Robot Meta endpoints try { $global_robot_meta_endpoint = new \ThinkRank\API\Global_Robot_Meta_Endpoint(); $global_robot_meta_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register Global Robot Meta endpoint } // Register Author Archives endpoints try { $author_archives_endpoint = new \ThinkRank\API\Author_Archives_Endpoint(); $author_archives_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register Author Archives endpoint } // Register Role Manager endpoint try { $role_manager_endpoint = new \ThinkRank\API\Role_Manager_Endpoint(); $role_manager_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register Role Manager endpoint } // Register Email Report endpoints try { $email_report_endpoint = new \ThinkRank\API\Email_Report_Endpoint(); $email_report_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register Email Report endpoint } register_rest_route(self::NAMESPACE, '/ai/status', [ 'methods' => 'GET', 'callback' => [$this, 'get_ai_status'], 'permission_callback' => [$this, 'check_basic_permissions'], ]); register_rest_route(self::NAMESPACE, '/ai/analyze-content', [ 'methods' => 'POST', 'callback' => [$this, 'analyze_content'], 'permission_callback' => [$this, 'check_basic_permissions'], 'args' => [ 'content' => [ 'type' => 'string', 'required' => true, 'sanitize_callback' => [$this, 'sanitize_ai_content'], ], 'metadata' => [ 'type' => 'object', 'required' => false, 'sanitize_callback' => [$this, 'sanitize_metadata_object'], ], 'post_id' => [ 'type' => 'integer', 'required' => false, 'sanitize_callback' => 'absint', ], ], ]); } /** * Check basic permissions (for logged-in users) * * @param \WP_REST_Request $request Request object * @return bool|WP_Error Permission status */ public function check_basic_permissions(\WP_REST_Request $request) { // Allow access for logged-in users who can edit posts if (!is_user_logged_in()) { return new \WP_Error( 'rest_forbidden', __('You must be logged in to access this endpoint.', 'thinkrank'), ['status' => 401] ); } if (!current_user_can('edit_posts')) { return new \WP_Error( 'rest_forbidden', __('You do not have permission to access this endpoint.', 'thinkrank'), ['status' => 403] ); } return true; } /** * Simple transient-based rate limiter * * @param string $bucket_id Unique bucket per user/IP and route * @param int $limit Max requests per minute * @return bool|\WP_Error True if allowed, or WP_Error when rate limited */ private function enforce_rate_limit(string $bucket_id, int $limit) { $settings = \ThinkRank\Core\Settings::instance(); $enabled = (bool) $settings->get('enable_rate_limiting', true); if (!$enabled) { return true; } $now = time(); $window = 60; $key = 'thinkrank_rl_' . md5($bucket_id); $bucket = get_transient($key); if (!is_array($bucket)) { $bucket = ['start' => $now, 'count' => 0]; } if ($now - ($bucket['start'] ?? 0) >= $window) { $bucket = ['start' => $now, 'count' => 0]; } if (($bucket['count'] ?? 0) >= max(1, $limit)) { return new \WP_Error('rate_limited', __('Rate limit exceeded. Please wait a moment and try again.', 'thinkrank'), ['status' => 429]); } $bucket['count']++; set_transient($key, $bucket, $window); return true; } /** * Check admin permissions (for settings) * * @param \WP_REST_Request $request Request object * @return bool|WP_Error Permission status */ public function check_admin_permissions(\WP_REST_Request $request) { // Allow access for administrators only if (!is_user_logged_in()) { return new \WP_Error( 'rest_forbidden', __('You must be logged in to access this endpoint.', 'thinkrank'), ['status' => 401] ); } if (!current_user_can('manage_options')) { return new \WP_Error( 'rest_forbidden', __('You do not have permission to manage settings.', 'thinkrank'), ['status' => 403] ); } return true; } /** * Check Settings section permissions. * * Delegable via Role Manager: passes for administrators (bypass) and for * any role granted the `thinkrank_settings` capability. Used by the core * /settings routes so the "Settings & API Keys" area can be delegated. * * @param \WP_REST_Request $request Request object * @return bool|\WP_Error Permission status */ public function check_settings_permissions(\WP_REST_Request $request) { if (!is_user_logged_in()) { return new \WP_Error( 'rest_forbidden', __('You must be logged in to access this endpoint.', 'thinkrank'), ['status' => 401] ); } if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings')) { return new \WP_Error( 'rest_forbidden', __('You do not have permission to manage ThinkRank settings.', 'thinkrank'), ['status' => 403] ); } return true; } /** * Get user capabilities * * @param \WP_REST_Request $request Request object * @return \WP_REST_Response Response object */ public function get_capabilities(\WP_REST_Request $request): \WP_REST_Response { return new \WP_REST_Response([ 'manage_settings' => current_user_can('manage_options'), 'view_analytics' => current_user_can('edit_posts'), 'use_ai_features' => current_user_can('edit_posts'), ]); } /** * Get plugin information * * @param \WP_REST_Request $request Request object * @return \WP_REST_Response Response object */ public function get_plugin_info(\WP_REST_Request $request): \WP_REST_Response { return new \WP_REST_Response([ 'version' => THINKRANK_VERSION, 'name' => 'ThinkRank', 'description' => 'AI-native SEO plugin for WordPress', ]); } /** * Get system status * * @param \WP_REST_Request $request Request object * @return \WP_REST_Response Response object */ public function get_system_status(\WP_REST_Request $request): \WP_REST_Response { return new \WP_REST_Response([ 'status' => 'healthy', 'issues' => [], 'php_version' => PHP_VERSION, 'wp_version' => get_bloginfo('version'), ]); } /** * Get ThinkRank integration health for MCP/Abilities clients (see #188). * * Admin-gated diagnostic; never returns secret material. Delegates to the * shared reporter so the ability and this route stay in lock-step. * * @param \WP_REST_Request $request Request object * @return \WP_REST_Response Response object */ public function get_connection_status(\WP_REST_Request $request): \WP_REST_Response { return new \WP_REST_Response(\ThinkRank\Diagnostics\Connection_Status::report()); } /** * Get settings * * @param \WP_REST_Request $request Request object * @return \WP_REST_Response Response object */ public function get_settings(\WP_REST_Request $request): \WP_REST_Response { // Use Settings class for consistent access (handles decryption automatically) $settings_instance = \ThinkRank\Core\Settings::instance(); $settings = [ 'ai_provider' => $settings_instance->get('ai_provider', \ThinkRank\Core\Settings::AI_PROVIDER_NONE), 'openai_api_key' => $settings_instance->get('openai_api_key', ''), 'openai_model' => $settings_instance->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL), 'claude_api_key' => $settings_instance->get('claude_api_key', ''), 'claude_model' => $settings_instance->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL), 'gemini_api_key' => $settings_instance->get('gemini_api_key', ''), 'gemini_model' => $settings_instance->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL), 'openrouter_api_key' => $settings_instance->get('openrouter_api_key', ''), 'openrouter_model' => $settings_instance->get('openrouter_model', \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL), 'max_tokens' => $settings_instance->get('max_tokens', 1000), 'temperature' => $settings_instance->get('temperature', 0.7), 'cache_duration' => $settings_instance->get('cache_duration', 3600), 'keep_data_on_uninstall' => (bool) $settings_instance->get('keep_data_on_uninstall', true), 'enable_migration_tools' => (bool) $settings_instance->get('enable_migration_tools', false), 'enable_import_export' => (bool) $settings_instance->get('enable_import_export', false), 'google_account_connected' => (bool) $settings_instance->get('google_account_connected', false), 'enable_mcp' => (bool) $settings_instance->get('enable_mcp', false), ]; // Don't send full API keys to frontend for security - mask them, // revealing the first 5 and last 3 chars so the saved key is recognizable. if (!empty($settings['openai_api_key'])) { $settings['openai_api_key'] = $this->mask_ai_api_key($settings['openai_api_key']); } if (!empty($settings['claude_api_key'])) { $settings['claude_api_key'] = $this->mask_ai_api_key($settings['claude_api_key']); } if (!empty($settings['gemini_api_key'])) { $settings['gemini_api_key'] = $this->mask_ai_api_key($settings['gemini_api_key']); } if (!empty($settings['openrouter_api_key'])) { $settings['openrouter_api_key'] = $this->mask_ai_api_key($settings['openrouter_api_key']); } return new \WP_REST_Response($settings); } /** * Mask an AI provider API key for display. * * Reveals the first 5 and last 3 characters with a bullet run in between * (e.g. "sk-pr••••••••abc"). Keys of 8 chars or fewer are fully masked so * head + tail can't reconstruct the whole value. The "••••••••" sentinel is * what save_settings() looks for to skip re-saving a resubmitted mask. * * @param string $key Raw API key. * @return string Masked key safe to send to the frontend. */ private function mask_ai_api_key(string $key): string { if (strlen($key) <= 8) { return '••••••••'; } return substr($key, 0, 5) . '••••••••' . substr($key, -3); } /** * Save settings * * @param \WP_REST_Request $request Request object * @return \WP_REST_Response Response object */ public function save_settings(\WP_REST_Request $request): \WP_REST_Response { $params = $request->get_params(); // Get Settings instance for proper encryption handling $settings = \ThinkRank\Core\Settings::instance(); // Capture the pre-save MCP state so we can detect an on/off transition // below and mint/revoke the connection token to match (see #244). $mcp_was_enabled = (bool) $settings->get('enable_mcp', false); // Map frontend parameter names to setting keys $settings_map = [ 'ai_provider' => 'ai_provider', 'openai_api_key' => 'openai_api_key', 'openai_model' => 'openai_model', 'claude_api_key' => 'claude_api_key', 'claude_model' => 'claude_model', 'gemini_api_key' => 'gemini_api_key', 'gemini_model' => 'gemini_model', 'openrouter_api_key' => 'openrouter_api_key', 'openrouter_model' => 'openrouter_model', 'max_tokens' => 'max_tokens', 'temperature' => 'temperature', 'cache_duration' => 'cache_duration', 'keep_data_on_uninstall' => 'keep_data_on_uninstall', 'enable_mcp' => 'enable_mcp', 'enable_migration_tools' => 'enable_migration_tools', 'enable_import_export' => 'enable_import_export', ]; // Processing settings save request foreach ($settings_map as $param_key => $setting_key) { if (isset($params[$param_key])) { $value = $params[$param_key]; // Handle API keys specially - check for masked values if (in_array($param_key, ['openai_api_key', 'claude_api_key', 'gemini_api_key', 'openrouter_api_key'], true)) { // Don't update if the value carries the mask sentinel (the // preview now keeps real head/tail chars around it, so match // anywhere rather than only at the start). Empty still clears. if (strpos($value, '••••••••') !== false) { continue; } } // Use Settings class for all operations (handles encryption automatically) // A failed write is skipped rather than aborting the batch, so // one bad setting cannot block the rest of the save. $settings->set($setting_key, $value); } } // MCP is a single master switch (see #244): enabling it auto-mints a // read/write connection token so the connect recipes are ready without // a separate "Generate token" step. // // Disabling is a PAUSE, not a wipe: the switch alone already denies all // access (Mcp_Server 403s and the OAuth/discovery endpoints refuse while // off), so stored tokens and OAuth grants are inert. We keep them so // re-enabling restores every previously connected app with no // re-approval. Explicit revocation stays available per-app (the // Connected AI apps trash button) and for the shared token (Reset // token / rotate). Only act on an actual on->off->on transition so // saving unrelated settings never touches the connection. if (isset($params['enable_mcp'])) { $mcp_now_enabled = (bool) $settings->get('enable_mcp', false); if ($mcp_now_enabled && !$mcp_was_enabled) { \ThinkRank\Mcp\Mcp_Pairing::connect(); } } // Auto-dismiss welcome notice if API key was saved $this->maybe_dismiss_welcome_notice($params); // Force AI Manager to re-initialize client with new settings if (isset($params['ai_provider']) || isset($params['openai_api_key']) || isset($params['claude_api_key']) || isset($params['gemini_api_key']) || isset($params['openrouter_api_key'])) { // Clear any cached AI Manager instances to force re-initialization wp_cache_delete('thinkrank_ai_manager', 'thinkrank'); // If we have an AI Manager instance, force it to re-initialize try { $ai_manager = new \ThinkRank\AI\Manager($settings); $ai_manager->reinitialize_client(); } catch (\Exception $e) { // Ignore initialization errors at this point } } return new \WP_REST_Response([ 'success' => true, 'message' => __('Settings saved successfully', 'thinkrank'), 'settings' => $this->get_settings($request)->get_data(), ]); } /** * Maybe dismiss welcome notice if API key was saved * * @param array $params Request parameters * @return void */ private function maybe_dismiss_welcome_notice(array $params): void { // Check if an API key was saved (not cleared) $api_key_saved = false; if (!empty($params['openai_api_key']) && $params['openai_api_key'] !== '') { $api_key_saved = true; } if (!empty($params['claude_api_key']) && $params['claude_api_key'] !== '') { $api_key_saved = true; } // Auto-dismiss welcome notice if API key was configured if ($api_key_saved && get_option('thinkrank_show_welcome')) { delete_option('thinkrank_show_welcome'); } } /** * Get metadata for post * * @param \WP_REST_Request $request Request object * @return \WP_REST_Response Response object */ public function get_metadata(\WP_REST_Request $request): \WP_REST_Response { $post_id = (int) $request->get_param('post_id'); // Object-level guard: only expose a post's stored SEO meta to a user who // can edit that specific post (the section capability gate handles the // AI Tools toggle; this adds per-post ownership). if (!current_user_can('edit_post', $post_id)) { return new \WP_REST_Response(['message' => 'You are not allowed to view this metadata.'], 403); } // Read the pending flag BEFORE the meta below, never after. A writer // that finishes mid-request writes the meta and *then* clears the // flag; reading the flag last could therefore observe "no value" and // "not pending" for the same run and stop the editor panel polling one // tick before the value it was waiting for lands (#329). $pending = \ThinkRank\SEO\Metadata_Pending::is_pending($post_id); // Get existing metadata. These must read the same canonical meta keys // the rest of the plugin writes/reads (frontend, metabox, scoring), // otherwise the response is always empty: // title/description → _thinkrank_seo_title / _thinkrank_meta_description // keywords → Focus_Keywords (stored as _thinkrank_focus_keywords) // last_generated → _thinkrank_generated_at (written by Metadata_Generator) $metadata = [ 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true), 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true), 'keywords' => \ThinkRank\SEO\Focus_Keywords::get($post_id), 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true) ?: 0, 'last_generated' => get_post_meta($post_id, '_thinkrank_generated_at', true), // Whether a background writer (Auto AI on publish, bulk // optimization, imports) is about to fill these fields. The editor // panel polls only while this is true. 'pending' => $pending, ]; return new \WP_REST_Response($metadata); } /** * Generate AI-powered SEO metadata * * @param \WP_REST_Request $request Request object containing content and generation options * @return \WP_REST_Response Response object with generated metadata or error message * @throws \Exception When AI metadata generation fails or AI client is unavailable */ public function generate_ai_metadata(\WP_REST_Request $request): \WP_REST_Response { $content = $request->get_param('content'); $options = [ 'target_keyword' => $request->get_param('target_keyword'), 'content_type' => $request->get_param('content_type'), 'tone' => $request->get_param('tone'), // Instruct the model to write in the post/site language instead of // defaulting to English on non-English sites (issue #234). 'language' => \ThinkRank\AI\Language_Resolver::resolve((int) $request->get_param('post_id')), ]; // Rate limiting: per user/IP per route $user_id = get_current_user_id(); $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput $bucket_id = 'ai_generate|' . ($user_id ?: $ip); $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10); $allowed = $this->enforce_rate_limit($bucket_id, $limit); if (is_wp_error($allowed)) { return new \WP_REST_Response([ 'success' => false, 'message' => $allowed->get_error_message(), ], $allowed->get_error_data()['status'] ?? 429); } try { // Get AI manager instance $ai_manager = new \ThinkRank\AI\Manager(); $ai_manager->initialize_client(); // Run through the generator so title/description are capped to the // configured limits and the character counts are returned. $generator = new \ThinkRank\AI\Metadata_Generator($ai_manager); $metadata = $generator->generate_for_content($content, $options); return new \WP_REST_Response([ 'success' => true, 'data' => $metadata, 'message' => __('SEO metadata generated successfully', 'thinkrank'), ]); } catch (\Exception $e) { return new \WP_REST_Response([ 'success' => false, 'message' => $e->getMessage(), ], 400); } } /** * Generate and return an improved SEO title for an "Apply" suggestion action. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response Response with the improved title under data.title. * @throws \Exception When title improvement fails or the AI client is unavailable. */ public function improve_ai_title(\WP_REST_Request $request): \WP_REST_Response { $content = $request->get_param('content'); $options = [ 'current_title' => $request->get_param('current_title'), 'target_keyword' => $request->get_param('target_keyword'), 'content_type' => $request->get_param('content_type'), 'tone' => $request->get_param('tone'), 'suggestion' => $request->get_param('suggestion'), 'language' => \ThinkRank\AI\Language_Resolver::resolve((int) $request->get_param('post_id')), ]; // Rate limiting: shares the AI generation bucket. $user_id = get_current_user_id(); $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput $bucket_id = 'ai_generate|' . ($user_id ?: $ip); $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10); $allowed = $this->enforce_rate_limit($bucket_id, $limit); if (is_wp_error($allowed)) { return new \WP_REST_Response([ 'success' => false, 'message' => $allowed->get_error_message(), ], $allowed->get_error_data()['status'] ?? 429); } try { $ai_manager = new \ThinkRank\AI\Manager(); $ai_manager->initialize_client(); $result = $ai_manager->improve_seo_title($content, $options); return new \WP_REST_Response([ 'success' => true, 'data' => $result, 'message' => __('SEO title improved successfully', 'thinkrank'), ]); } catch (\Exception $e) { return new \WP_REST_Response([ 'success' => false, 'message' => $e->getMessage(), ], 400); } } /** * Generate and return an improved meta description for an "Apply" action. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response Response with the description under data.description. * @throws \Exception When generation fails or the AI client is unavailable. */ public function improve_ai_meta_description(\WP_REST_Request $request): \WP_REST_Response { $content = $request->get_param('content'); $options = [ 'current_description' => $request->get_param('current_description'), 'target_keyword' => $request->get_param('target_keyword'), 'content_type' => $request->get_param('content_type'), 'tone' => $request->get_param('tone'), 'suggestion' => $request->get_param('suggestion'), 'language' => \ThinkRank\AI\Language_Resolver::resolve((int) $request->get_param('post_id')), ]; // Rate limiting: shares the AI generation bucket. $user_id = get_current_user_id(); $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput $bucket_id = 'ai_generate|' . ($user_id ?: $ip); $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10); $allowed = $this->enforce_rate_limit($bucket_id, $limit); if (is_wp_error($allowed)) { return new \WP_REST_Response([ 'success' => false, 'message' => $allowed->get_error_message(), ], $allowed->get_error_data()['status'] ?? 429); } try { $ai_manager = new \ThinkRank\AI\Manager(); $ai_manager->initialize_client(); $result = $ai_manager->improve_meta_description($content, $options); return new \WP_REST_Response([ 'success' => true, 'data' => $result, 'message' => __('Meta description generated successfully', 'thinkrank'), ]); } catch (\Exception $e) { return new \WP_REST_Response([ 'success' => false, 'message' => $e->getMessage(), ], 400); } } /** * Explain a single SEO suggestion in plain, post-specific language. * * Read-only copilot action: returns a short AI explanation of why the * suggestion matters for this post and how to resolve it. Does not modify * any content. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response Response with the explanation under data.explanation. * @throws \Exception When generation fails or the AI client is unavailable. */ public function explain_ai_suggestion(\WP_REST_Request $request): \WP_REST_Response { $content = $request->get_param('content'); $options = [ 'suggestion' => $request->get_param('suggestion'), 'title' => $request->get_param('title'), 'target_keyword' => $request->get_param('target_keyword'), 'content_type' => $request->get_param('content_type'), ]; // Rate limiting: shares the AI generation bucket. $user_id = get_current_user_id(); $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput $bucket_id = 'ai_generate|' . ($user_id ?: $ip); $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10); $allowed = $this->enforce_rate_limit($bucket_id, $limit); if (is_wp_error($allowed)) { return new \WP_REST_Response([ 'success' => false, 'message' => $allowed->get_error_message(), ], $allowed->get_error_data()['status'] ?? 429); } try { $ai_manager = new \ThinkRank\AI\Manager(); $ai_manager->initialize_client(); $result = $ai_manager->explain_seo_suggestion($content, $options); return new \WP_REST_Response([ 'success' => true, 'data' => $result, 'message' => __('Explanation generated successfully', 'thinkrank'), ]); } catch (\Exception $e) { return new \WP_REST_Response([ 'success' => false, 'message' => $e->getMessage(), ], 400); } } /** * Generate a content fragment with one authoritative external dofollow link. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response Response with the HTML fragment under data.html. * @throws \Exception When generation fails or the AI client is unavailable. */ public function add_ai_dofollow_link(\WP_REST_Request $request): \WP_REST_Response { $content = $request->get_param('content'); $options = [ 'target_keyword' => $request->get_param('target_keyword'), 'content_type' => $request->get_param('content_type'), ]; // Rate limiting: shares the AI generation bucket. $user_id = get_current_user_id(); $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput $bucket_id = 'ai_generate|' . ($user_id ?: $ip); $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10); $allowed = $this->enforce_rate_limit($bucket_id, $limit); if (is_wp_error($allowed)) { return new \WP_REST_Response([ 'success' => false, 'message' => $allowed->get_error_message(), ], $allowed->get_error_data()['status'] ?? 429); } try { $ai_manager = new \ThinkRank\AI\Manager(); $ai_manager->initialize_client(); $result = $ai_manager->generate_dofollow_link($content, $options); return new \WP_REST_Response([ 'success' => true, 'data' => $result, 'message' => __('Added an authoritative source link', 'thinkrank'), ]); } catch (\Exception $e) { return new \WP_REST_Response([ 'success' => false, 'message' => $e->getMessage(), ], 400); } } /** * Generate a keyword-rich paragraph to lift keyword density into band. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response Response with the HTML fragment under data.html. * @throws \Exception When generation fails or the AI client is unavailable. */ public function add_ai_keyword_paragraph(\WP_REST_Request $request): \WP_REST_Response { $content = $request->get_param('content'); $options = [ 'target_keyword' => $request->get_param('target_keyword'), 'content_type' => $request->get_param('content_type'), 'tone' => $request->get_param('tone'), 'word_count' => $request->get_param('word_count'), 'keyword_count' => $request->get_param('keyword_count'), ]; // Rate limiting: shares the AI generation bucket. $user_id = get_current_user_id(); $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput $bucket_id = 'ai_generate|' . ($user_id ?: $ip); $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10); $allowed = $this->enforce_rate_limit($bucket_id, $limit); if (is_wp_error($allowed)) { return new \WP_REST_Response([ 'success' => false, 'message' => $allowed->get_error_message(), ], $allowed->get_error_data()['status'] ?? 429); } try { $ai_manager = new \ThinkRank\AI\Manager(); $ai_manager->initialize_client(); $result = $ai_manager->generate_keyword_paragraph($content, $options); return new \WP_REST_Response([ 'success' => true, 'data' => $result, 'message' => __('Added a keyword-focused paragraph', 'thinkrank'), ]); } catch (\Exception $e) { return new \WP_REST_Response([ 'success' => false, 'message' => $e->getMessage(), ], 400); } } /** * Enable ThinkRank's Global SEO schema output for a post's post type. * * Sets a sensible default schema type (Article for posts, WebPage for pages) * when none is configured yet, so ThinkRank emits JSON-LD for the post. This * resolves the "add structured data" suggestion, which the scorer now credits * when ThinkRank schema is active. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response Response describing the enabled schema type. */ public function enable_schema_for_post(\WP_REST_Request $request): \WP_REST_Response { $post_id = (int) $request->get_param('post_id'); $post = get_post($post_id); if (!$post) { return new \WP_REST_Response([ 'success' => false, 'message' => __('Post not found.', 'thinkrank'), ], 404); } $post_type = $post->post_type; $settings = get_option('thinkrank_global_seo_settings', []); if (!is_array($settings)) { $settings = []; } if (!isset($settings[$post_type]) || !is_array($settings[$post_type])) { $settings[$post_type] = []; } $already_enabled = !empty($settings[$post_type]['schema_type']); if (!$already_enabled) { if ($post_type === 'page') { $settings[$post_type]['schema_type'] = 'WebPage'; } else { $settings[$post_type]['schema_type'] = 'Article'; if (empty($settings[$post_type]['article_type'])) { $settings[$post_type]['article_type'] = 'BlogPosting'; } } update_option('thinkrank_global_seo_settings', $settings); } $schema_type = $settings[$post_type]['schema_type']; return new \WP_REST_Response([ 'success' => true, 'data' => [ 'schema_type' => $schema_type, 'post_type' => $post_type, 'already_enabled' => $already_enabled, ], 'message' => $already_enabled ? __('Schema was already enabled for this post type.', 'thinkrank') /* translators: %s: schema type. */ : sprintf(__('Enabled %s schema for this post type.', 'thinkrank'), $schema_type), ]); } /** * Test AI connection for specified provider * * @param \WP_REST_Request $request Request object containing api_key and provider parameters * @return \WP_REST_Response Response object with connection test results * @throws \Exception When API connection test encounters unexpected errors */ public function test_ai_connection(\WP_REST_Request $request): \WP_REST_Response { // Rate limiting: per user/IP per route $user_id = get_current_user_id(); $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput $bucket_id = 'ai_test|' . ($user_id ?: $ip); $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10); $allowed = $this->enforce_rate_limit($bucket_id, $limit); if (is_wp_error($allowed)) { return new \WP_REST_Response([ 'success' => false, 'message' => $allowed->get_error_message(), ], $allowed->get_error_data()['status'] ?? 429); } try { $api_key = $request->get_param('api_key'); $provider = $request->get_param('provider') ?: 'openai'; // The model the caller is asking about. Empty means "whatever is // saved" — the settings screen sends the model currently on screen // so an unsaved pick or a hand-typed id is what actually gets // tested, rather than the last saved one. $model = trim((string) $request->get_param('model')); // An unrecognised provider used to fall through to the Gemini arm // below, so a typo silently tested the wrong provider's key. if (!in_array($provider, \ThinkRank\Core\Settings::SUPPORTED_AI_PROVIDERS, true)) { return new \WP_REST_Response([ 'success' => false, /* translators: %s: the unrecognised provider value. */ 'message' => sprintf(__('Unknown AI provider: %s', 'thinkrank'), $provider), ], 400); } // If no API key provided in request, try to get from saved settings if (empty($api_key)) { $settings = \ThinkRank\Core\Settings::instance(); if ($provider === 'openai') { $api_key = (string) $settings->get('openai_api_key', ''); } elseif ($provider === 'claude') { $api_key = (string) $settings->get('claude_api_key', ''); } elseif ($provider === 'openrouter') { $api_key = (string) $settings->get('openrouter_api_key', ''); } else { $api_key = (string) $settings->get('gemini_api_key', ''); } if (empty($api_key)) { return new \WP_REST_Response([ 'success' => false, 'message' => __('No API key provided or saved for the selected provider.', 'thinkrank'), ], 400); } } // Test the connection with a simple API call if ($provider === 'openai') { $result = $this->test_openai_connection($api_key, $model); } elseif ($provider === 'claude') { $result = $this->test_claude_connection($api_key, $model); } elseif ($provider === 'openrouter') { $result = $this->test_openrouter_connection($api_key, $model); } else { $result = $this->test_gemini_connection($api_key, $model); } return new \WP_REST_Response($result, $result['success'] ? 200 : 400); } catch (\Exception $e) { return new \WP_REST_Response([ 'success' => false, 'message' => $e->getMessage(), ], 500); } } /** * Test OpenAI API connection * * The models endpoint doubles as the model check: it answers with every id * this key may call, so an unknown or unentitled model is caught here * instead of at the first real generation. * * @param string $api_key API key to test * @param string $model Model id to verify, or '' to use the saved one * @return array Test result */ private function test_openai_connection(string $api_key, string $model = ''): array { $model = $model !== '' ? $model : (string) \ThinkRank\Core\Settings::instance()->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL); $url = 'https://api.openai.com/v1/models'; $response = wp_remote_get($url, [ 'headers' => [ 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', ], 'timeout' => 10, ]); if (is_wp_error($response)) { return [ 'success' => false, 'message' => __('Failed to connect to OpenAI API: ', 'thinkrank') . $response->get_error_message(), ]; } $status_code = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); if ($status_code === 200) { $data = json_decode($body, true); if (isset($data['data']) && is_array($data['data'])) { $ids = array_column($data['data'], 'id'); if ($model !== '' && !in_array($model, $ids, true)) { return [ 'success' => false, 'model' => $model, 'model_available' => false, /* translators: %s: the model id that was tested. */ 'message' => sprintf(__('API key works, but the model "%s" is not available to this account.', 'thinkrank'), $model), ]; } return [ 'success' => true, 'model' => $model, 'model_available' => $model !== '', 'message' => $model !== '' /* translators: %s: the model id that was tested. */ ? sprintf(__('OpenAI API connection successful — model "%s" is available.', 'thinkrank'), $model) : __('OpenAI API connection successful!', 'thinkrank'), 'models_count' => count($data['data']), ]; } } // Handle error response $error_data = json_decode($body, true); $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank'); return [ 'success' => false, 'message' => __('OpenAI API Error: ', 'thinkrank') . $error_message, ]; } /** * Test OpenRouter API connection * * @param string $api_key API key to test * @param string $model Model id to verify, or '' to use the saved one * @return array Test result */ private function test_openrouter_connection(string $api_key, string $model = ''): array { $model = $model !== '' ? $model : (string) \ThinkRank\Core\Settings::instance()->get('openrouter_model', \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL); // Validate the key format first (OpenRouter keys start with "sk-or-"). if (!str_starts_with($api_key, 'sk-or-')) { return [ 'success' => false, 'message' => __('Invalid OpenRouter API key format. Should start with "sk-or-"', 'thinkrank'), ]; } // The key endpoint validates the credential and returns its metadata. $url = 'https://openrouter.ai/api/v1/key'; $response = wp_remote_get($url, [ 'headers' => [ 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', 'HTTP-Referer' => home_url('/'), 'X-Title' => 'ThinkRank', ], 'timeout' => 10, ]); if (is_wp_error($response)) { return [ 'success' => false, 'message' => __('Failed to connect to OpenRouter API: ', 'thinkrank') . $response->get_error_message(), ]; } $status_code = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); if ($status_code === 200) { $data = json_decode($body, true); if (isset($data['data']) && is_array($data['data'])) { // The key is good; the catalogue is a separate document, so // the model needs its own lookup. if ($model !== '') { $model_check = $this->check_openrouter_model($api_key, $model); if ($model_check !== null) { return $model_check; } } return [ 'success' => true, 'model' => $model, 'model_available' => $model !== '', 'message' => $model !== '' /* translators: %s: the model id that was tested. */ ? sprintf(__('OpenRouter API connection successful — model "%s" is available.', 'thinkrank'), $model) : __('OpenRouter API connection successful!', 'thinkrank'), ]; } } // Handle error response $error_data = json_decode($body, true); $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank'); return [ 'success' => false, 'message' => __('OpenRouter API Error: ', 'thinkrank') . $error_message, ]; } /** * Verify a model id against OpenRouter's public catalogue. * * @param string $api_key API key to authenticate the lookup * @param string $model Model id to look for * @return array|null Failure payload when the model is unknown, null when it * is available or when the catalogue could not be read — * a listing hiccup must not fail an otherwise good key. */ private function check_openrouter_model(string $api_key, string $model): ?array { $response = wp_remote_get('https://openrouter.ai/api/v1/models', [ 'headers' => [ 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', 'HTTP-Referer' => home_url('/'), 'X-Title' => 'ThinkRank', ], 'timeout' => 10, ]); if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) { return null; } $data = json_decode(wp_remote_retrieve_body($response), true); if (!isset($data['data']) || !is_array($data['data'])) { return null; } $ids = array_column($data['data'], 'id'); if (in_array($model, $ids, true)) { return null; } return [ 'success' => false, 'model' => $model, 'model_available' => false, /* translators: %s: the model id that was tested. */ 'message' => sprintf(__('API key works, but "%s" is not a model OpenRouter offers.', 'thinkrank'), $model), ]; } /** * Test Claude API connection * * @param string $api_key API key to test * @param string $model Model id to verify, or '' to use the saved one * @return array Test result */ private function test_claude_connection(string $api_key, string $model = ''): array { // First validate the key format if (!str_starts_with($api_key, 'sk-ant-')) { return [ 'success' => false, 'message' => __('Invalid Claude API key format. Should start with "sk-ant-"', 'thinkrank'), ]; } // Test with a simple API call $url = 'https://api.anthropic.com/v1/messages'; // A model sent with the request is tested verbatim: normalizing it would // quietly swap a typo for a working id and report success for a model // the user never asked for. Only the saved fallback is self-healed, as // that is the path where a retired id from an older release shows up. if ($model !== '') { $claude_model = $model; } else { $claude_model = \ThinkRank\Core\Settings::instance()->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL); $claude_model = \ThinkRank\AI\Claude_Client::normalize_model($claude_model); } $body = [ 'model' => $claude_model, 'max_tokens' => 10, 'messages' => [ [ 'role' => 'user', 'content' => 'Hello' ] ] ]; $response = wp_remote_post($url, [ 'headers' => [ 'x-api-key' => $api_key, 'Content-Type' => 'application/json', 'anthropic-version' => '2023-06-01', ], 'body' => wp_json_encode($body), 'timeout' => 10, ]); if (is_wp_error($response)) { return [ 'success' => false, 'message' => __('Failed to connect to Claude API: ', 'thinkrank') . $response->get_error_message(), ]; } $status_code = wp_remote_retrieve_response_code($response); $response_body = wp_remote_retrieve_body($response); if ($status_code === 200) { return [ 'success' => true, 'model' => $claude_model, 'model_available' => true, /* translators: %s: the model id that was tested. */ 'message' => sprintf(__('Claude API connection successful — model "%s" is available.', 'thinkrank'), $claude_model), ]; } else { $error_data = json_decode($response_body, true); $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank'); // 404 on /v1/messages means the key authenticated but the model id // does not exist — say so, instead of blaming the key. if ($status_code === 404) { return [ 'success' => false, 'model' => $claude_model, 'model_available' => false, /* translators: %s: the model id that was tested. */ 'message' => sprintf(__('API key works, but the model "%s" was not found.', 'thinkrank'), $claude_model), ]; } return [ 'success' => false, 'model' => $claude_model, /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */ 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message), ]; } } /** * Test Gemini API connection * * @param string $api_key API key to test * @param string $model Model id to verify, or '' to use the saved one * @return array Test result */ private function test_gemini_connection(string $api_key, string $model = ''): array { // Test with a simple API call $gemini_model = $model !== '' ? $model : (string) \ThinkRank\Core\Settings::instance()->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL); // The model is a path segment, and ids may arrive with the "models/" // prefix Google's own docs use. $gemini_model = ltrim($gemini_model, '/'); $gemini_model = preg_replace('#^models/#', '', $gemini_model); $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . rawurlencode($gemini_model) . ':generateContent?key=' . rawurlencode($api_key); $body = [ 'contents' => [ [ 'parts' => [ ['text' => 'Hello'] ] ] ], 'generationConfig' => [ 'maxOutputTokens' => 10, 'temperature' => 0.1, ] ]; $response = wp_remote_post($url, [ 'headers' => [ 'Content-Type' => 'application/json', ], 'body' => wp_json_encode($body), 'timeout' => 10, ]); if (is_wp_error($response)) { return [ 'success' => false, 'message' => __('Failed to connect to Gemini API: ', 'thinkrank') . $response->get_error_message(), ]; } $status_code = wp_remote_retrieve_response_code($response); $response_body = wp_remote_retrieve_body($response); if ($status_code === 200) { return [ 'success' => true, 'model' => $gemini_model, 'model_available' => true, /* translators: %s: the model id that was tested. */ 'message' => sprintf(__('Gemini API connection successful — model "%s" is available.', 'thinkrank'), $gemini_model), ]; } else { $error_data = json_decode($response_body, true); $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank'); // Gemini answers 404 for a model id it does not serve; the key // itself authenticated fine, so name the real problem. if ($status_code === 404) { return [ 'success' => false, 'model' => $gemini_model, 'model_available' => false, /* translators: %s: the model id that was tested. */ 'message' => sprintf(__('API key works, but the model "%s" was not found.', 'thinkrank'), $gemini_model), ]; } return [ 'success' => false, 'model' => $gemini_model, /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */ 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message), ]; } } /** * Get AI providers * * @param \WP_REST_Request $request Request object * @return \WP_REST_Response Response object */ public function get_ai_providers(\WP_REST_Request $request): \WP_REST_Response { $ai_manager = new \ThinkRank\AI\Manager(); $providers = $ai_manager->get_available_providers(); return new \WP_REST_Response($providers); } /** * Get AI status * * @param \WP_REST_Request $request Request object * @return \WP_REST_Response Response object */ public function get_ai_status(\WP_REST_Request $request): \WP_REST_Response { $ai_manager = new \ThinkRank\AI\Manager(); $status = $ai_manager->get_provider_status(); return new \WP_REST_Response($status); } /** * Analyze content for SEO optimization * * @param \WP_REST_Request $request Request object containing content, metadata, and optional post_id * @return \WP_REST_Response Response object with analysis results or error message * @throws \Exception When AI analysis fails or AI client initialization fails */ public function analyze_content(\WP_REST_Request $request): \WP_REST_Response { $content = $request->get_param('content'); $metadata = $request->get_param('metadata') ?: []; $post_id = $request->get_param('post_id'); // Rate limiting: per user/IP per route $user_id = get_current_user_id(); $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput $bucket_id = 'ai_analyze|' . ($user_id ?: $ip); $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10); $allowed = $this->enforce_rate_limit($bucket_id, $limit); if (is_wp_error($allowed)) { return new \WP_REST_Response([ 'success' => false, 'message' => $allowed->get_error_message(), ], $allowed->get_error_data()['status'] ?? 429); } try { // Get AI manager instance $ai_manager = new \ThinkRank\AI\Manager(); $ai_manager->initialize_client(); // Perform content analysis $analysis = $ai_manager->analyze_content($content, $metadata); return new \WP_REST_Response([ 'success' => true, 'data' => $analysis, 'message' => __('Content analyzed successfully', 'thinkrank'), ]); } catch (\Exception $e) { return new \WP_REST_Response([ 'success' => false, 'message' => $e->getMessage(), ], 400); } } /** * Sanitize metadata object for API endpoints * * @param mixed $metadata Metadata to sanitize * @return array Sanitized metadata array */ public function sanitize_metadata_object($metadata): array { if (!is_array($metadata)) { return []; } $sanitized = []; foreach ($metadata as $key => $value) { $sanitized_key = sanitize_key($key); if (is_string($value)) { $sanitized[$sanitized_key] = sanitize_text_field($value); } elseif (is_array($value)) { // Recursively sanitize nested arrays $sanitized[$sanitized_key] = array_map('sanitize_text_field', $value); } elseif (is_numeric($value)) { $sanitized[$sanitized_key] = (float) $value; } elseif (is_bool($value)) { $sanitized[$sanitized_key] = (bool) $value; } // Skip other data types for security } return $sanitized; } /** * Register endpoint classes * * @return void */ public function register_endpoint_classes(): void { // Register endpoint classes that exist try { $site_identity_endpoint = new Site_Identity_Endpoint(); $site_identity_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register Site Identity endpoint } try { $ai_insights_endpoint = new Ai_Insights_Endpoint(); $ai_insights_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register AI Insights endpoint } try { $performance_endpoint = new Performance_Endpoint(); $performance_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register Performance endpoint } try { $schema_endpoint = new Schema_Endpoint(); $schema_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register Schema endpoint } try { $settings_endpoint = new Settings_Management_Endpoint(); $settings_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register Settings Management endpoint } try { $integrations_endpoint = new Integrations_Endpoint(); $integrations_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register Integrations endpoint } try { $social_platforms_endpoint = new Social_Platforms_Endpoint(); $social_platforms_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register Social Platforms endpoint } try { $content_brief_endpoint = new Content_Brief_Endpoint(); $content_brief_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register Content Brief endpoint } try { $social_media_endpoint = new Social_Media_Endpoint(); $social_media_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register Social Media endpoint } try { $sitemap_endpoint = new Sitemap_Endpoint(); $sitemap_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register Sitemap endpoint } try { $llms_txt_endpoint = new LLMs_Txt_Endpoint(); $llms_txt_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register LLMs.txt endpoint } try { $global_seo_endpoint = new Global_SEO_Endpoint(); $global_seo_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register Global SEO endpoint } try { $content_type_matrix_endpoint = new \ThinkRank\API\Content_Type_Matrix_Endpoint(); $content_type_matrix_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register Content Type Matrix endpoint } try { $image_seo_endpoint = new Image_SEO_Endpoint(); $image_seo_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register Image SEO endpoint } try { $external_links_endpoint = new External_Links_Endpoint(); $external_links_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register External Links endpoint } // Import_Controller is deliberately NOT gated on enable_migration_tools. // /import/detect backs the setup wizard's migration step and the record // count on Settings > Import / Export, and /import/snapshot + /migrate // run the wizard's actual import — all on a fresh install, where the // setting is off. Gating them would break onboarding, which is a worse // bug than the one #583 reports. try { $import_controller = new Import_Controller(); $import_controller->register_routes(); } catch (\Exception $e) { // Failed to register Import endpoint } // Export/restore is gated on the setting that gates its admin screen, // so turning Import / Export off removes its REST surface along with // its menu item (#583). Nothing in the setup wizard calls these: // MigrationPluginRow takes startExport/startMigration/cancel from // useImportWorkflow and never uploadFile. rest_api_init runs per // request, so a toggle takes effect on the next one — no flush. if ((bool) \ThinkRank\Core\Settings::instance()->get('enable_import_export', false)) { try { $export_controller = new Export_Controller(); $export_controller->register_routes(); } catch (\Exception $e) { // Failed to register Export endpoint } } try { $setup_wizard_endpoint = new Setup_Wizard_Endpoint(); $setup_wizard_endpoint->register_routes(); } catch (\Exception $e) { // Failed to register Setup Wizard endpoint } } }