PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.0.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.0.1
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.0.1, at includes/api/class-manager.php

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