PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.1.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.1.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / api / class-manager.php

class-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.1.0, at includes/api/class-manager.php

1,043 lines 36.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * API Manager Class
4 *
5 * Handles REST API endpoints registration and management
6 *
7 * @package ThinkRank\API
8 * @since 1.0.0
9 */
10
11 declare(strict_types=1);
12
13 namespace ThinkRank\API;
14
15 // Import endpoint classes
16 use ThinkRank\API\Site_Identity_Endpoint;
17 use ThinkRank\API\Performance_Endpoint;
18
19 use ThinkRank\API\Schema_Endpoint;
20 use ThinkRank\API\Settings_Management_Endpoint;
21 use ThinkRank\API\Content_Brief_Endpoint;
22 use ThinkRank\API\Social_Media_Endpoint;
23 use ThinkRank\API\Sitemap_Endpoint;
24 use ThinkRank\API\SEO_Analytics_Endpoint;
25 use ThinkRank\API\Usage_Analytics_Endpoint;
26 use ThinkRank\API\Integrations_Endpoint;
27 use ThinkRank\API\Social_Platforms_Endpoint;
28 use ThinkRank\API\LLMs_Txt_Endpoint;
29 use ThinkRank\API\Global_SEO_Endpoint;
30
31 // Prevent direct access
32 if (!defined('ABSPATH')) {
33 exit;
34 }
35
36 /**
37 * API Manager Class
38 *
39 * Single Responsibility: Manage REST API endpoints
40 *
41 * @since 1.0.0
42 */
43 class Manager {
44
45 /**
46 * API namespace
47 *
48 * @var string
49 */
50 private const NAMESPACE = 'thinkrank/v1';
51
52 /**
53 * Initialize API manager
54 *
55 * @return void
56 */
57 public function init(): void {
58 add_action('rest_api_init', [$this, 'register_routes']);
59 add_action('rest_api_init', [$this, 'register_endpoint_classes']);
60 }
61
62 /**
63 * Register REST API routes
64 *
65 * @return void
66 */
67 public function register_routes(): void {
68 // Core endpoints
69 register_rest_route(self::NAMESPACE, '/capabilities', [
70 'methods' => 'GET',
71 'callback' => [$this, 'get_capabilities'],
72 'permission_callback' => [$this, 'check_basic_permissions'],
73 ]);
74
75 register_rest_route(self::NAMESPACE, '/plugin-info', [
76 'methods' => 'GET',
77 'callback' => [$this, 'get_plugin_info'],
78 'permission_callback' => [$this, 'check_basic_permissions'],
79 ]);
80
81 register_rest_route(self::NAMESPACE, '/system-status', [
82 'methods' => 'GET',
83 'callback' => [$this, 'get_system_status'],
84 'permission_callback' => [$this, 'check_basic_permissions'],
85 ]);
86
87
88
89 // Settings endpoints
90 register_rest_route(self::NAMESPACE, '/settings', [
91 'methods' => 'GET',
92 'callback' => [$this, 'get_settings'],
93 'permission_callback' => [$this, 'check_admin_permissions'],
94 ]);
95
96 register_rest_route(self::NAMESPACE, '/settings', [
97 'methods' => 'POST',
98 'callback' => [$this, 'save_settings'],
99 'permission_callback' => [$this, 'check_admin_permissions'],
100 'args' => [
101 'ai_provider' => [
102 'type' => 'string',
103 'sanitize_callback' => 'sanitize_key',
104 ],
105 'openai_api_key' => [
106 'type' => 'string',
107 'sanitize_callback' => 'sanitize_text_field',
108 ],
109 'openai_model' => [
110 'type' => 'string',
111 'sanitize_callback' => 'sanitize_text_field',
112 ],
113 'claude_api_key' => [
114 'type' => 'string',
115 'sanitize_callback' => 'sanitize_text_field',
116 ],
117 'claude_model' => [
118 'type' => 'string',
119 'sanitize_callback' => 'sanitize_text_field',
120 ],
121 'gemini_api_key' => [
122 'type' => 'string',
123 'sanitize_callback' => 'sanitize_text_field',
124 ],
125 'gemini_model' => [
126 'type' => 'string',
127 'sanitize_callback' => 'sanitize_text_field',
128 ],
129 'keep_data_on_uninstall' => [
130 'type' => 'boolean',
131 'sanitize_callback' => 'rest_sanitize_boolean',
132 ],
133 ],
134 ]);
135
136
137
138 // Metadata endpoints
139 register_rest_route(self::NAMESPACE, '/metadata/(?P<post_id>\d+)', [
140 'methods' => 'GET',
141 'callback' => [$this, 'get_metadata'],
142 'permission_callback' => [$this, 'check_basic_permissions'],
143 'args' => [
144 'post_id' => [
145 'type' => 'integer',
146 'required' => true,
147 ],
148 ],
149 ]);
150
151 // AI endpoints
152 register_rest_route(self::NAMESPACE, '/ai/generate-metadata', [
153 'methods' => 'POST',
154 'callback' => [$this, 'generate_ai_metadata'],
155 'permission_callback' => [$this, 'check_basic_permissions'],
156 'args' => [
157 'content' => [
158 'type' => 'string',
159 'required' => true,
160 'sanitize_callback' => 'sanitize_textarea_field',
161 ],
162 'target_keyword' => [
163 'type' => 'string',
164 'sanitize_callback' => 'sanitize_text_field',
165 ],
166 'content_type' => [
167 'type' => 'string',
168 'default' => 'blog_post',
169 'sanitize_callback' => 'sanitize_text_field',
170 ],
171 'tone' => [
172 'type' => 'string',
173 'default' => 'professional',
174 'sanitize_callback' => 'sanitize_text_field',
175 ],
176 ],
177 ]);
178
179 register_rest_route(self::NAMESPACE, '/ai/test-connection', [
180 'methods' => 'POST',
181 'callback' => [$this, 'test_ai_connection'],
182 'permission_callback' => [$this, 'check_admin_permissions'],
183 'args' => [
184 'api_key' => [
185 'type' => 'string',
186 'required' => false,
187 'sanitize_callback' => 'sanitize_text_field',
188 ],
189 'provider' => [
190 'type' => 'string',
191 'required' => false,
192 'default' => 'openai',
193 'sanitize_callback' => 'sanitize_key',
194 ],
195 ],
196 ]);
197
198 register_rest_route(self::NAMESPACE, '/ai/providers', [
199 'methods' => 'GET',
200 'callback' => [$this, 'get_ai_providers'],
201 'permission_callback' => [$this, 'check_basic_permissions'],
202 ]);
203
204 // Register content brief endpoints
205 $content_brief_endpoint = new \ThinkRank\API\Content_Brief_Endpoint();
206 $content_brief_endpoint->register_routes();
207
208 // Register SEO score endpoints
209 try {
210 $database = new \ThinkRank\Core\Database();
211 $seo_calculator = new \ThinkRank\AI\SEOScoreCalculator($database);
212 $seo_score_endpoint = new \ThinkRank\API\SEOScoreEndpoint($seo_calculator);
213 $seo_score_endpoint->register_routes();
214 } catch (\Exception $e) {
215 // SEO Score endpoint registration failed
216 }
217
218 // Register Usage Analytics endpoints
219 try {
220 $usage_analytics_endpoint = new \ThinkRank\API\Usage_Analytics_Endpoint();
221 $usage_analytics_endpoint->register_routes();
222 } catch (\Exception $e) {
223 // Usage Analytics endpoint registration failed
224 }
225
226 // Register SEO Analytics endpoints
227 try {
228 $seo_analytics_endpoint = new \ThinkRank\API\SEO_Analytics_Endpoint();
229 $seo_analytics_endpoint->register_routes();
230 } catch (\Exception $e) {
231 // Failed to register SEO Analytics endpoint
232 }
233
234
235
236 // Add a simple test endpoint to verify API is working
237 register_rest_route(self::NAMESPACE, '/seo-score/test', [
238 'methods' => 'GET',
239 'callback' => function() {
240 return ['message' => 'SEO Score API is working!', 'timestamp' => current_time('mysql')];
241 },
242 'permission_callback' => [$this, 'check_basic_permissions'],
243 ]);
244
245 register_rest_route(self::NAMESPACE, '/ai/status', [
246 'methods' => 'GET',
247 'callback' => [$this, 'get_ai_status'],
248 'permission_callback' => [$this, 'check_basic_permissions'],
249 ]);
250
251 register_rest_route(self::NAMESPACE, '/ai/analyze-content', [
252 'methods' => 'POST',
253 'callback' => [$this, 'analyze_content'],
254 'permission_callback' => [$this, 'check_basic_permissions'],
255 'args' => [
256 'content' => [
257 'type' => 'string',
258 'required' => true,
259 'sanitize_callback' => 'sanitize_textarea_field',
260 ],
261 'metadata' => [
262 'type' => 'object',
263 'required' => false,
264 'sanitize_callback' => [$this, 'sanitize_metadata_object'],
265 ],
266 'post_id' => [
267 'type' => 'integer',
268 'required' => false,
269 'sanitize_callback' => 'absint',
270 ],
271 ],
272 ]);
273 }
274
275 /**
276 * Check basic permissions (for logged-in users)
277 *
278 * @param \WP_REST_Request $request Request object
279 * @return bool|WP_Error Permission status
280 */
281 public function check_basic_permissions(\WP_REST_Request $request) {
282 // Allow access for logged-in users who can edit posts
283 if (!is_user_logged_in()) {
284 return new \WP_Error(
285 'rest_forbidden',
286 __('You must be logged in to access this endpoint.', 'thinkrank'),
287 ['status' => 401]
288 );
289 }
290
291 if (!current_user_can('edit_posts')) {
292 return new \WP_Error(
293 'rest_forbidden',
294 __('You do not have permission to access this endpoint.', 'thinkrank'),
295 ['status' => 403]
296 );
297 }
298
299 return true;
300 }
301
302
303 /**
304 * Simple transient-based rate limiter
305 *
306 * @param string $bucket_id Unique bucket per user/IP and route
307 * @param int $limit Max requests per minute
308 * @return bool|\WP_Error True if allowed, or WP_Error when rate limited
309 */
310 private function enforce_rate_limit(string $bucket_id, int $limit) {
311 $settings = new \ThinkRank\Core\Settings();
312 $enabled = (bool) $settings->get('enable_rate_limiting', true);
313 if (!$enabled) {
314 return true;
315 }
316 $now = time();
317 $window = 60;
318 $key = 'thinkrank_rl_' . md5($bucket_id);
319 $bucket = get_transient($key);
320 if (!is_array($bucket)) {
321 $bucket = ['start' => $now, 'count' => 0];
322 }
323 if ($now - ($bucket['start'] ?? 0) >= $window) {
324 $bucket = ['start' => $now, 'count' => 0];
325 }
326 if (($bucket['count'] ?? 0) >= max(1, $limit)) {
327 return new \WP_Error('rate_limited', __('Rate limit exceeded. Please wait a moment and try again.', 'thinkrank'), ['status' => 429]);
328 }
329 $bucket['count']++;
330 set_transient($key, $bucket, $window);
331 return true;
332 }
333
334 /**
335 * Check admin permissions (for settings)
336 *
337 * @param \WP_REST_Request $request Request object
338 * @return bool|WP_Error Permission status
339 */
340 public function check_admin_permissions(\WP_REST_Request $request) {
341 // Allow access for administrators only
342 if (!is_user_logged_in()) {
343 return new \WP_Error(
344 'rest_forbidden',
345 __('You must be logged in to access this endpoint.', 'thinkrank'),
346 ['status' => 401]
347 );
348 }
349
350 if (!current_user_can('manage_options')) {
351 return new \WP_Error(
352 'rest_forbidden',
353 __('You do not have permission to manage settings.', 'thinkrank'),
354 ['status' => 403]
355 );
356 }
357
358 return true;
359 }
360
361 /**
362 * Get user capabilities
363 *
364 * @param \WP_REST_Request $request Request object
365 * @return \WP_REST_Response Response object
366 */
367 public function get_capabilities(\WP_REST_Request $request): \WP_REST_Response {
368 return new \WP_REST_Response([
369 'manage_settings' => current_user_can('manage_options'),
370 'view_analytics' => current_user_can('edit_posts'),
371
372 'use_ai_features' => current_user_can('edit_posts'),
373 ]);
374 }
375
376 /**
377 * Get plugin information
378 *
379 * @param \WP_REST_Request $request Request object
380 * @return \WP_REST_Response Response object
381 */
382 public function get_plugin_info(\WP_REST_Request $request): \WP_REST_Response {
383 return new \WP_REST_Response([
384 'version' => THINKRANK_VERSION,
385 'name' => 'ThinkRank',
386 'description' => 'AI-native SEO plugin for WordPress',
387 ]);
388 }
389
390 /**
391 * Get system status
392 *
393 * @param \WP_REST_Request $request Request object
394 * @return \WP_REST_Response Response object
395 */
396 public function get_system_status(\WP_REST_Request $request): \WP_REST_Response {
397 return new \WP_REST_Response([
398 'status' => 'healthy',
399 'issues' => [],
400 'php_version' => PHP_VERSION,
401 'wp_version' => get_bloginfo('version'),
402 ]);
403 }
404
405
406
407 /**
408 * Get settings
409 *
410 * @param \WP_REST_Request $request Request object
411 * @return \WP_REST_Response Response object
412 */
413 public function get_settings(\WP_REST_Request $request): \WP_REST_Response {
414 // Use Settings class for consistent access (handles decryption automatically)
415 $settings_instance = new \ThinkRank\Core\Settings();
416
417 $settings = [
418 'ai_provider' => $settings_instance->get('ai_provider', 'openai'),
419 'openai_api_key' => $settings_instance->get('openai_api_key', ''),
420 'openai_model' => $settings_instance->get('openai_model', 'gpt-5-nano'),
421 'claude_api_key' => $settings_instance->get('claude_api_key', ''),
422 'claude_model' => $settings_instance->get('claude_model', 'claude-3-7-sonnet-latest'),
423 'gemini_api_key' => $settings_instance->get('gemini_api_key', ''),
424 'gemini_model' => $settings_instance->get('gemini_model', 'gemini-2.5-flash'),
425 'max_tokens' => $settings_instance->get('max_tokens', 1000),
426 'temperature' => $settings_instance->get('temperature', 0.7),
427 'cache_duration' => $settings_instance->get('cache_duration', 3600),
428 'keep_data_on_uninstall' => $settings_instance->get('keep_data_on_uninstall', true),
429 ];
430
431
432
433 // Don't send full API keys to frontend for security - mask them
434 if (!empty($settings['openai_api_key'])) {
435 $settings['openai_api_key'] = '••••••••' . substr($settings['openai_api_key'], -4);
436 }
437 if (!empty($settings['claude_api_key'])) {
438 $settings['claude_api_key'] = '••••••••' . substr($settings['claude_api_key'], -4);
439 }
440 if (!empty($settings['gemini_api_key'])) {
441 $settings['gemini_api_key'] = '••••••••' . substr($settings['gemini_api_key'], -4);
442 }
443
444 return new \WP_REST_Response($settings);
445 }
446
447 /**
448 * Save settings
449 *
450 * @param \WP_REST_Request $request Request object
451 * @return \WP_REST_Response Response object
452 */
453 public function save_settings(\WP_REST_Request $request): \WP_REST_Response {
454 $params = $request->get_params();
455
456 // Get Settings instance for proper encryption handling
457 $settings = new \ThinkRank\Core\Settings();
458
459 // Map frontend parameter names to setting keys
460 $settings_map = [
461 'ai_provider' => 'ai_provider',
462 'openai_api_key' => 'openai_api_key',
463 'openai_model' => 'openai_model',
464 'claude_api_key' => 'claude_api_key',
465 'claude_model' => 'claude_model',
466 'gemini_api_key' => 'gemini_api_key',
467 'gemini_model' => 'gemini_model',
468 'max_tokens' => 'max_tokens',
469 'temperature' => 'temperature',
470 'cache_duration' => 'cache_duration',
471 'keep_data_on_uninstall' => 'keep_data_on_uninstall',
472 ];
473
474 // Processing settings save request
475
476 foreach ($settings_map as $param_key => $setting_key) {
477 if (isset($params[$param_key])) {
478 $value = $params[$param_key];
479
480 // Handle API keys specially - check for masked values
481 if (in_array($param_key, ['openai_api_key', 'claude_api_key', 'gemini_api_key'])) {
482 // Don't update if masked (but allow empty to clear)
483 if (strpos($value, '••••••••') === 0) {
484 continue;
485 }
486 }
487
488 // Use Settings class for all operations (handles encryption automatically)
489 if (!$settings->set($setting_key, $value)) {
490 // Settings save failed, continue with other settings
491 }
492 }
493 }
494
495 // Auto-dismiss welcome notice if API key was saved
496 $this->maybe_dismiss_welcome_notice($params);
497
498 // Force AI Manager to re-initialize client with new settings
499 if (isset($params['ai_provider']) || isset($params['openai_api_key']) || isset($params['claude_api_key']) || isset($params['gemini_api_key'])) {
500 // Clear any cached AI Manager instances to force re-initialization
501 wp_cache_delete('thinkrank_ai_manager', 'thinkrank');
502
503 // If we have an AI Manager instance, force it to re-initialize
504 try {
505 $ai_manager = new \ThinkRank\AI\Manager($settings);
506 $ai_manager->reinitialize_client();
507 } catch (\Exception $e) {
508 // Ignore initialization errors at this point
509 }
510 }
511
512 return new \WP_REST_Response([
513 'success' => true,
514 'message' => __('Settings saved successfully', 'thinkrank'),
515 'settings' => $this->get_settings($request)->get_data(),
516 ]);
517 }
518
519 /**
520 * Maybe dismiss welcome notice if API key was saved
521 *
522 * @param array $params Request parameters
523 * @return void
524 */
525 private function maybe_dismiss_welcome_notice(array $params): void {
526 // Check if an API key was saved (not cleared)
527 $api_key_saved = false;
528
529 if (!empty($params['openai_api_key']) && $params['openai_api_key'] !== '') {
530 $api_key_saved = true;
531 }
532
533 if (!empty($params['claude_api_key']) && $params['claude_api_key'] !== '') {
534 $api_key_saved = true;
535 }
536
537 // Auto-dismiss welcome notice if API key was configured
538 if ($api_key_saved && get_option('thinkrank_show_welcome')) {
539 delete_option('thinkrank_show_welcome');
540 }
541 }
542
543 /**
544 * Get metadata for post
545 *
546 * @param \WP_REST_Request $request Request object
547 * @return \WP_REST_Response Response object
548 */
549 public function get_metadata(\WP_REST_Request $request): \WP_REST_Response {
550 $post_id = $request->get_param('post_id');
551
552 // Get existing metadata
553 $metadata = [
554 'title' => get_post_meta($post_id, '_thinkrank_title', true),
555 'description' => get_post_meta($post_id, '_thinkrank_description', true),
556 'keywords' => get_post_meta($post_id, '_thinkrank_keywords', true),
557 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true) ?: 0,
558 'last_generated' => get_post_meta($post_id, '_thinkrank_last_generated', true),
559 ];
560
561 return new \WP_REST_Response($metadata);
562 }
563
564 /**
565 * Generate AI-powered SEO metadata
566 *
567 * @param \WP_REST_Request $request Request object containing content and generation options
568 * @return \WP_REST_Response Response object with generated metadata or error message
569 * @throws \Exception When AI metadata generation fails or AI client is unavailable
570 */
571 public function generate_ai_metadata(\WP_REST_Request $request): \WP_REST_Response {
572 $content = $request->get_param('content');
573 $options = [
574 'target_keyword' => $request->get_param('target_keyword'),
575 'content_type' => $request->get_param('content_type'),
576 'tone' => $request->get_param('tone'),
577 ];
578
579 // Rate limiting: per user/IP per route
580 $user_id = get_current_user_id();
581 $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
582 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
583 $limit = (int) (new \ThinkRank\Core\Settings())->get('max_requests_per_minute', 10);
584 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
585 if (is_wp_error($allowed)) {
586 return new \WP_REST_Response([
587 'success' => false,
588 'message' => $allowed->get_error_message(),
589 ], $allowed->get_error_data()['status'] ?? 429);
590 }
591
592 try {
593 // Get AI manager instance
594 $ai_manager = new \ThinkRank\AI\Manager();
595 $ai_manager->initialize_client();
596
597 $metadata = $ai_manager->generate_seo_metadata($content, $options);
598
599 return new \WP_REST_Response([
600 'success' => true,
601 'data' => $metadata,
602 'message' => __('SEO metadata generated successfully', 'thinkrank'),
603 ]);
604
605 } catch (\Exception $e) {
606 return new \WP_REST_Response([
607 'success' => false,
608 'message' => $e->getMessage(),
609 ], 400);
610 }
611 }
612
613 /**
614 * Test AI connection for specified provider
615 *
616 * @param \WP_REST_Request $request Request object containing api_key and provider parameters
617 * @return \WP_REST_Response Response object with connection test results
618 * @throws \Exception When API connection test encounters unexpected errors
619 */
620 public function test_ai_connection(\WP_REST_Request $request): \WP_REST_Response {
621 // Rate limiting: per user/IP per route
622 $user_id = get_current_user_id();
623 $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
624 $bucket_id = 'ai_test|' . ($user_id ?: $ip);
625 $limit = (int) (new \ThinkRank\Core\Settings())->get('max_requests_per_minute', 10);
626 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
627 if (is_wp_error($allowed)) {
628 return new \WP_REST_Response([
629 'success' => false,
630 'message' => $allowed->get_error_message(),
631 ], $allowed->get_error_data()['status'] ?? 429);
632 }
633
634 try {
635 $api_key = $request->get_param('api_key');
636 $provider = $request->get_param('provider') ?: 'openai';
637
638 // If no API key provided in request, try to get from saved settings
639 if (empty($api_key)) {
640 $settings = new \ThinkRank\Core\Settings();
641 if ($provider === 'openai') {
642 $api_key = (string) $settings->get('openai_api_key', '');
643 } elseif ($provider === 'claude') {
644 $api_key = (string) $settings->get('claude_api_key', '');
645 } else {
646 $api_key = (string) $settings->get('gemini_api_key', '');
647 }
648
649 if (empty($api_key)) {
650 return new \WP_REST_Response([
651 'success' => false,
652 'message' => __('No API key provided or saved for the selected provider.', 'thinkrank'),
653 ], 400);
654 }
655 }
656
657 // Test the connection with a simple API call
658 if ($provider === 'openai') {
659 $result = $this->test_openai_connection($api_key);
660 } elseif ($provider === 'claude') {
661 $result = $this->test_claude_connection($api_key);
662 } else {
663 $result = $this->test_gemini_connection($api_key);
664 }
665
666 return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
667
668 } catch (\Exception $e) {
669 return new \WP_REST_Response([
670 'success' => false,
671 'message' => $e->getMessage(),
672 ], 500);
673 }
674 }
675
676 /**
677 * Test OpenAI API connection
678 *
679 * @param string $api_key API key to test
680 * @return array Test result
681 */
682 private function test_openai_connection(string $api_key): array {
683 $url = 'https://api.openai.com/v1/models';
684
685 $response = wp_remote_get($url, [
686 'headers' => [
687 'Authorization' => 'Bearer ' . $api_key,
688 'Content-Type' => 'application/json',
689 ],
690 'timeout' => 10,
691 ]);
692
693 if (is_wp_error($response)) {
694 return [
695 'success' => false,
696 'message' => __('Failed to connect to OpenAI API: ', 'thinkrank') . $response->get_error_message(),
697 ];
698 }
699
700 $status_code = wp_remote_retrieve_response_code($response);
701 $body = wp_remote_retrieve_body($response);
702
703 if ($status_code === 200) {
704 $data = json_decode($body, true);
705 if (isset($data['data']) && is_array($data['data'])) {
706 return [
707 'success' => true,
708 'message' => __('OpenAI API connection successful!', 'thinkrank'),
709 'models_count' => count($data['data']),
710 ];
711 }
712 }
713
714 // Handle error response
715 $error_data = json_decode($body, true);
716 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
717
718 return [
719 'success' => false,
720 'message' => __('OpenAI API Error: ', 'thinkrank') . $error_message,
721 ];
722 }
723
724 /**
725 * Test Claude API connection
726 *
727 * @param string $api_key API key to test
728 * @return array Test result
729 */
730 private function test_claude_connection(string $api_key): array {
731 // First validate the key format
732 if (!str_starts_with($api_key, 'sk-ant-')) {
733 return [
734 'success' => false,
735 'message' => __('Invalid Claude API key format. Should start with "sk-ant-"', 'thinkrank'),
736 ];
737 }
738
739 // Test with a simple API call
740 $url = 'https://api.anthropic.com/v1/messages';
741
742 // Get the configured Claude model, with fallback to a current model
743 $claude_model = (new \ThinkRank\Core\Settings())->get('claude_model', 'claude-3-7-sonnet-latest');
744
745 $body = [
746 'model' => $claude_model,
747 'max_tokens' => 10,
748 'messages' => [
749 [
750 'role' => 'user',
751 'content' => 'Hello'
752 ]
753 ]
754 ];
755
756 $response = wp_remote_post($url, [
757 'headers' => [
758 'x-api-key' => $api_key,
759 'Content-Type' => 'application/json',
760 'anthropic-version' => '2023-06-01',
761 ],
762 'body' => wp_json_encode($body),
763 'timeout' => 10,
764 ]);
765
766 if (is_wp_error($response)) {
767 return [
768 'success' => false,
769 'message' => __('Failed to connect to Claude API: ', 'thinkrank') . $response->get_error_message(),
770 ];
771 }
772
773 $status_code = wp_remote_retrieve_response_code($response);
774 $response_body = wp_remote_retrieve_body($response);
775
776 if ($status_code === 200) {
777 return [
778 'success' => true,
779 'message' => __('Claude API connection successful!', 'thinkrank'),
780 ];
781 } else {
782 $error_data = json_decode($response_body, true);
783 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
784
785 return [
786 'success' => false,
787 /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */
788 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
789 ];
790 }
791 }
792
793 /**
794 * Test Gemini API connection
795 *
796 * @param string $api_key API key to test
797 * @return array Test result
798 */
799 private function test_gemini_connection(string $api_key): array {
800 // Test with a simple API call
801 $gemini_model = (new \ThinkRank\Core\Settings())->get('gemini_model', 'gemini-2.5-flash');
802 $url = "https://generativelanguage.googleapis.com/v1beta/models/{$gemini_model}:generateContent?key={$api_key}";
803
804 $body = [
805 'contents' => [
806 [
807 'parts' => [
808 ['text' => 'Hello']
809 ]
810 ]
811 ],
812 'generationConfig' => [
813 'maxOutputTokens' => 10,
814 'temperature' => 0.1,
815 ]
816 ];
817
818 $response = wp_remote_post($url, [
819 'headers' => [
820 'Content-Type' => 'application/json',
821 ],
822 'body' => wp_json_encode($body),
823 'timeout' => 10,
824 ]);
825
826 if (is_wp_error($response)) {
827 return [
828 'success' => false,
829 'message' => __('Failed to connect to Gemini API: ', 'thinkrank') . $response->get_error_message(),
830 ];
831 }
832
833 $status_code = wp_remote_retrieve_response_code($response);
834 $response_body = wp_remote_retrieve_body($response);
835
836 if ($status_code === 200) {
837 return [
838 'success' => true,
839 'message' => __('Gemini API connection successful!', 'thinkrank'),
840 ];
841 } else {
842 $error_data = json_decode($response_body, true);
843 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
844
845 return [
846 'success' => false,
847 /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */
848 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
849 ];
850 }
851 }
852
853 /**
854 * Get AI providers
855 *
856 * @param \WP_REST_Request $request Request object
857 * @return \WP_REST_Response Response object
858 */
859 public function get_ai_providers(\WP_REST_Request $request): \WP_REST_Response {
860 $ai_manager = new \ThinkRank\AI\Manager();
861 $providers = $ai_manager->get_available_providers();
862
863 return new \WP_REST_Response($providers);
864 }
865
866 /**
867 * Get AI status
868 *
869 * @param \WP_REST_Request $request Request object
870 * @return \WP_REST_Response Response object
871 */
872 public function get_ai_status(\WP_REST_Request $request): \WP_REST_Response {
873 $ai_manager = new \ThinkRank\AI\Manager();
874 $status = $ai_manager->get_provider_status();
875
876 return new \WP_REST_Response($status);
877 }
878
879 /**
880 * Analyze content for SEO optimization
881 *
882 * @param \WP_REST_Request $request Request object containing content, metadata, and optional post_id
883 * @return \WP_REST_Response Response object with analysis results or error message
884 * @throws \Exception When AI analysis fails or AI client initialization fails
885 */
886 public function analyze_content(\WP_REST_Request $request): \WP_REST_Response {
887 $content = $request->get_param('content');
888 $metadata = $request->get_param('metadata') ?: [];
889 $post_id = $request->get_param('post_id');
890
891 // Rate limiting: per user/IP per route
892 $user_id = get_current_user_id();
893 $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
894 $bucket_id = 'ai_analyze|' . ($user_id ?: $ip);
895 $limit = (int) (new \ThinkRank\Core\Settings())->get('max_requests_per_minute', 10);
896 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
897 if (is_wp_error($allowed)) {
898 return new \WP_REST_Response([
899 'success' => false,
900 'message' => $allowed->get_error_message(),
901 ], $allowed->get_error_data()['status'] ?? 429);
902 }
903
904 try {
905 // Get AI manager instance
906 $ai_manager = new \ThinkRank\AI\Manager();
907 $ai_manager->initialize_client();
908
909 // Perform content analysis
910 $analysis = $ai_manager->analyze_content($content, $metadata);
911
912 return new \WP_REST_Response([
913 'success' => true,
914 'data' => $analysis,
915 'message' => __('Content analyzed successfully', 'thinkrank'),
916 ]);
917
918 } catch (\Exception $e) {
919 return new \WP_REST_Response([
920 'success' => false,
921 'message' => $e->getMessage(),
922 ], 400);
923 }
924 }
925
926 /**
927 * Sanitize metadata object for API endpoints
928 *
929 * @param mixed $metadata Metadata to sanitize
930 * @return array Sanitized metadata array
931 */
932 public function sanitize_metadata_object($metadata): array {
933 if (!is_array($metadata)) {
934 return [];
935 }
936
937 $sanitized = [];
938 foreach ($metadata as $key => $value) {
939 $sanitized_key = sanitize_key($key);
940
941 if (is_string($value)) {
942 $sanitized[$sanitized_key] = sanitize_text_field($value);
943 } elseif (is_array($value)) {
944 // Recursively sanitize nested arrays
945 $sanitized[$sanitized_key] = array_map('sanitize_text_field', $value);
946 } elseif (is_numeric($value)) {
947 $sanitized[$sanitized_key] = (float) $value;
948 } elseif (is_bool($value)) {
949 $sanitized[$sanitized_key] = (bool) $value;
950 }
951 // Skip other data types for security
952 }
953
954 return $sanitized;
955 }
956
957 /**
958 * Register endpoint classes
959 *
960 * @return void
961 */
962 public function register_endpoint_classes(): void {
963 // Register endpoint classes that exist
964 try {
965 $site_identity_endpoint = new Site_Identity_Endpoint();
966 $site_identity_endpoint->register_routes();
967 } catch (\Exception $e) {
968 // Failed to register Site Identity endpoint
969 }
970
971 try {
972 $performance_endpoint = new Performance_Endpoint();
973 $performance_endpoint->register_routes();
974 } catch (\Exception $e) {
975 // Failed to register Performance endpoint
976 }
977
978 try {
979 $schema_endpoint = new Schema_Endpoint();
980 $schema_endpoint->register_routes();
981 } catch (\Exception $e) {
982 // Failed to register Schema endpoint
983 }
984
985 try {
986 $settings_endpoint = new Settings_Management_Endpoint();
987 $settings_endpoint->register_routes();
988 } catch (\Exception $e) {
989 // Failed to register Settings Management endpoint
990 }
991
992 try {
993 $integrations_endpoint = new Integrations_Endpoint();
994 $integrations_endpoint->register_routes();
995 } catch (\Exception $e) {
996 // Failed to register Integrations endpoint
997 }
998
999 try {
1000 $social_platforms_endpoint = new Social_Platforms_Endpoint();
1001 $social_platforms_endpoint->register_routes();
1002 } catch (\Exception $e) {
1003 // Failed to register Social Platforms endpoint
1004 }
1005
1006 try {
1007 $content_brief_endpoint = new Content_Brief_Endpoint();
1008 $content_brief_endpoint->register_routes();
1009 } catch (\Exception $e) {
1010 // Failed to register Content Brief endpoint
1011 }
1012
1013 try {
1014 $social_media_endpoint = new Social_Media_Endpoint();
1015 $social_media_endpoint->register_routes();
1016 } catch (\Exception $e) {
1017 // Failed to register Social Media endpoint
1018 }
1019
1020 try {
1021 $sitemap_endpoint = new Sitemap_Endpoint();
1022 $sitemap_endpoint->register_routes();
1023 } catch (\Exception $e) {
1024 // Failed to register Sitemap endpoint
1025 }
1026
1027 try {
1028 $llms_txt_endpoint = new LLMs_Txt_Endpoint();
1029 $llms_txt_endpoint->register_routes();
1030 } catch (\Exception $e) {
1031 // Failed to register LLMs.txt endpoint
1032 }
1033
1034 try {
1035 $global_seo_endpoint = new Global_SEO_Endpoint();
1036 $global_seo_endpoint->register_routes();
1037 } catch (\Exception $e) {
1038 // Failed to register Global SEO endpoint
1039 }
1040 }
1041
1042 }
1043