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

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