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

1,797 lines 67.8 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 use ThinkRank\API\Email_Report_Endpoint;
36 use ThinkRank\Admin\Importers\Import_Controller;
37 use ThinkRank\API\Setup_Wizard_Endpoint;
38
39
40 // Prevent direct access
41 if (!defined('ABSPATH')) {
42 exit;
43 }
44
45 /**
46 * API Manager Class
47 *
48 * Single Responsibility: Manage REST API endpoints
49 *
50 * @since 1.0.0
51 */
52 class Manager {
53
54 /**
55 * API namespace
56 *
57 * @var string
58 */
59 private const NAMESPACE = 'thinkrank/v1';
60
61 /**
62 * Maximum accepted length (characters) for AI `content` payloads. Enforced
63 * at the REST boundary so oversized input can't drive expensive prompt
64 * building, cache hashing, and AI requests/retries. Mirrors the frontend's
65 * 5000-character trim.
66 */
67 private const AI_CONTENT_MAX_LENGTH = 5000;
68
69 /**
70 * Sanitize and hard-cap an AI `content` request parameter.
71 *
72 * Used as the `sanitize_callback` for every AI endpoint's `content` arg so
73 * the server enforces its own maximum regardless of what a direct REST
74 * caller sends.
75 *
76 * @param mixed $value Raw request value.
77 * @return string Sanitized content, truncated to AI_CONTENT_MAX_LENGTH.
78 */
79 public function sanitize_ai_content($value): string {
80 return mb_substr(sanitize_textarea_field((string) $value), 0, self::AI_CONTENT_MAX_LENGTH);
81 }
82
83 /**
84 * Initialize API manager
85 *
86 * @return void
87 */
88 public function init(): void {
89 add_action('rest_api_init', [$this, 'register_routes']);
90 add_action('rest_api_init', [$this, 'register_endpoint_classes']);
91 }
92
93 /**
94 * Register REST API routes
95 *
96 * @return void
97 */
98 public function register_routes(): void {
99 // Core endpoints
100 register_rest_route(self::NAMESPACE, '/capabilities', [
101 'methods' => 'GET',
102 'callback' => [$this, 'get_capabilities'],
103 'permission_callback' => [$this, 'check_basic_permissions'],
104 ]);
105
106 register_rest_route(self::NAMESPACE, '/plugin-info', [
107 'methods' => 'GET',
108 'callback' => [$this, 'get_plugin_info'],
109 'permission_callback' => [$this, 'check_basic_permissions'],
110 ]);
111
112 register_rest_route(self::NAMESPACE, '/system-status', [
113 'methods' => 'GET',
114 'callback' => [$this, 'get_system_status'],
115 'permission_callback' => [$this, 'check_basic_permissions'],
116 ]);
117
118 // Integration health check for MCP/Abilities clients (see #188). This
119 // route is intentionally gated only by the admin capability, NOT by the
120 // `enable_mcp` toggle, so it stays reachable as a diagnostic even when
121 // the MCP server is off or abilities failed to register.
122 register_rest_route(self::NAMESPACE, '/connection-status', [
123 'methods' => 'GET',
124 'callback' => [$this, 'get_connection_status'],
125 'permission_callback' => [$this, 'check_admin_permissions'],
126 ]);
127
128 // Settings endpoints
129 register_rest_route(self::NAMESPACE, '/settings', [
130 'methods' => 'GET',
131 'callback' => [$this, 'get_settings'],
132 'permission_callback' => [$this, 'check_settings_permissions'],
133 ]);
134
135 register_rest_route(self::NAMESPACE, '/settings', [
136 'methods' => 'POST',
137 'callback' => [$this, 'save_settings'],
138 'permission_callback' => [$this, 'check_settings_permissions'],
139 'args' => [
140 'ai_provider' => [
141 'type' => 'string',
142 'sanitize_callback' => 'sanitize_key',
143 ],
144 'openai_api_key' => [
145 'type' => 'string',
146 'sanitize_callback' => 'sanitize_text_field',
147 ],
148 'openai_model' => [
149 'type' => 'string',
150 'sanitize_callback' => 'sanitize_text_field',
151 ],
152 'claude_api_key' => [
153 'type' => 'string',
154 'sanitize_callback' => 'sanitize_text_field',
155 ],
156 'claude_model' => [
157 'type' => 'string',
158 'sanitize_callback' => 'sanitize_text_field',
159 ],
160 'gemini_api_key' => [
161 'type' => 'string',
162 'sanitize_callback' => 'sanitize_text_field',
163 ],
164 'gemini_model' => [
165 'type' => 'string',
166 'sanitize_callback' => 'sanitize_text_field',
167 ],
168 'openrouter_api_key' => [
169 'type' => 'string',
170 'sanitize_callback' => 'sanitize_text_field',
171 ],
172 'openrouter_model' => [
173 'type' => 'string',
174 'sanitize_callback' => 'sanitize_text_field',
175 ],
176 'keep_data_on_uninstall' => [
177 'type' => 'boolean',
178 'sanitize_callback' => 'rest_sanitize_boolean',
179 ],
180 'enable_mcp' => [
181 'type' => 'boolean',
182 'sanitize_callback' => 'rest_sanitize_boolean',
183 ],
184 'enable_migration_tools' => [
185 'type' => 'boolean',
186 'sanitize_callback' => 'rest_sanitize_boolean',
187 ],
188 ],
189 ]);
190
191
192
193 // Metadata endpoints
194 register_rest_route(self::NAMESPACE, '/metadata/(?P<post_id>\d+)', [
195 'methods' => 'GET',
196 'callback' => [$this, 'get_metadata'],
197 'permission_callback' => [$this, 'check_basic_permissions'],
198 'args' => [
199 'post_id' => [
200 'type' => 'integer',
201 'required' => true,
202 ],
203 ],
204 ]);
205
206 // AI endpoints
207 register_rest_route(self::NAMESPACE, '/ai/generate-metadata', [
208 'methods' => 'POST',
209 'callback' => [$this, 'generate_ai_metadata'],
210 'permission_callback' => [$this, 'check_basic_permissions'],
211 'args' => [
212 'content' => [
213 'type' => 'string',
214 'required' => true,
215 'sanitize_callback' => [$this, 'sanitize_ai_content'],
216 ],
217 'target_keyword' => [
218 'type' => 'string',
219 'sanitize_callback' => 'sanitize_text_field',
220 ],
221 'content_type' => [
222 'type' => 'string',
223 'default' => 'blog_post',
224 'sanitize_callback' => 'sanitize_text_field',
225 ],
226 'tone' => [
227 'type' => 'string',
228 'default' => 'professional',
229 'sanitize_callback' => 'sanitize_text_field',
230 ],
231 ],
232 ]);
233
234 register_rest_route(self::NAMESPACE, '/ai/improve-title', [
235 'methods' => 'POST',
236 'callback' => [$this, 'improve_ai_title'],
237 'permission_callback' => [$this, 'check_basic_permissions'],
238 'args' => [
239 'content' => [
240 'type' => 'string',
241 'required' => true,
242 'sanitize_callback' => [$this, 'sanitize_ai_content'],
243 ],
244 'current_title' => [
245 'type' => 'string',
246 'sanitize_callback' => 'sanitize_text_field',
247 ],
248 'target_keyword' => [
249 'type' => 'string',
250 'sanitize_callback' => 'sanitize_text_field',
251 ],
252 'content_type' => [
253 'type' => 'string',
254 'default' => 'blog_post',
255 'sanitize_callback' => 'sanitize_text_field',
256 ],
257 'tone' => [
258 'type' => 'string',
259 'default' => 'professional',
260 'sanitize_callback' => 'sanitize_text_field',
261 ],
262 'suggestion' => [
263 'type' => 'string',
264 'sanitize_callback' => 'sanitize_text_field',
265 ],
266 ],
267 ]);
268
269 register_rest_route(self::NAMESPACE, '/ai/improve-meta-description', [
270 'methods' => 'POST',
271 'callback' => [$this, 'improve_ai_meta_description'],
272 'permission_callback' => [$this, 'check_basic_permissions'],
273 'args' => [
274 'content' => [
275 'type' => 'string',
276 'required' => true,
277 'sanitize_callback' => [$this, 'sanitize_ai_content'],
278 ],
279 'current_description' => [
280 'type' => 'string',
281 'sanitize_callback' => 'sanitize_textarea_field',
282 ],
283 'target_keyword' => [
284 'type' => 'string',
285 'sanitize_callback' => 'sanitize_text_field',
286 ],
287 'content_type' => [
288 'type' => 'string',
289 'default' => 'blog_post',
290 'sanitize_callback' => 'sanitize_text_field',
291 ],
292 'tone' => [
293 'type' => 'string',
294 'default' => 'professional',
295 'sanitize_callback' => 'sanitize_text_field',
296 ],
297 'suggestion' => [
298 'type' => 'string',
299 'sanitize_callback' => 'sanitize_text_field',
300 ],
301 ],
302 ]);
303
304 register_rest_route(self::NAMESPACE, '/ai/explain-suggestion', [
305 'methods' => 'POST',
306 'callback' => [$this, 'explain_ai_suggestion'],
307 'permission_callback' => [$this, 'check_basic_permissions'],
308 'args' => [
309 'content' => [
310 'type' => 'string',
311 'required' => true,
312 'sanitize_callback' => [$this, 'sanitize_ai_content'],
313 ],
314 'suggestion' => [
315 'type' => 'string',
316 'required' => true,
317 'sanitize_callback' => 'sanitize_text_field',
318 ],
319 'title' => [
320 'type' => 'string',
321 'sanitize_callback' => 'sanitize_text_field',
322 ],
323 'target_keyword' => [
324 'type' => 'string',
325 'sanitize_callback' => 'sanitize_text_field',
326 ],
327 'content_type' => [
328 'type' => 'string',
329 'default' => 'blog_post',
330 'sanitize_callback' => 'sanitize_text_field',
331 ],
332 ],
333 ]);
334
335 register_rest_route(self::NAMESPACE, '/ai/add-dofollow-link', [
336 'methods' => 'POST',
337 'callback' => [$this, 'add_ai_dofollow_link'],
338 'permission_callback' => [$this, 'check_basic_permissions'],
339 'args' => [
340 'content' => [
341 'type' => 'string',
342 'required' => true,
343 'sanitize_callback' => [$this, 'sanitize_ai_content'],
344 ],
345 'target_keyword' => [
346 'type' => 'string',
347 'sanitize_callback' => 'sanitize_text_field',
348 ],
349 'content_type' => [
350 'type' => 'string',
351 'default' => 'blog_post',
352 'sanitize_callback' => 'sanitize_text_field',
353 ],
354 ],
355 ]);
356
357 register_rest_route(self::NAMESPACE, '/ai/add-keyword-paragraph', [
358 'methods' => 'POST',
359 'callback' => [$this, 'add_ai_keyword_paragraph'],
360 'permission_callback' => [$this, 'check_basic_permissions'],
361 'args' => [
362 'content' => [
363 'type' => 'string',
364 'required' => true,
365 'sanitize_callback' => [$this, 'sanitize_ai_content'],
366 ],
367 'target_keyword' => [
368 'type' => 'string',
369 'required' => true,
370 'sanitize_callback' => 'sanitize_text_field',
371 ],
372 'content_type' => [
373 'type' => 'string',
374 'default' => 'blog_post',
375 'sanitize_callback' => 'sanitize_text_field',
376 ],
377 'tone' => [
378 'type' => 'string',
379 'default' => 'professional',
380 'sanitize_callback' => 'sanitize_text_field',
381 ],
382 'word_count' => [
383 'type' => 'integer',
384 'default' => 0,
385 'sanitize_callback' => 'absint',
386 ],
387 'keyword_count' => [
388 'type' => 'integer',
389 'default' => 0,
390 'sanitize_callback' => 'absint',
391 ],
392 ],
393 ]);
394
395 register_rest_route(self::NAMESPACE, '/schema/enable-for-post', [
396 'methods' => 'POST',
397 'callback' => [$this, 'enable_schema_for_post'],
398 'permission_callback' => [$this, 'check_admin_permissions'],
399 'args' => [
400 'post_id' => [
401 'type' => 'integer',
402 'required' => true,
403 'sanitize_callback' => 'absint',
404 ],
405 ],
406 ]);
407
408 register_rest_route(self::NAMESPACE, '/ai/test-connection', [
409 'methods' => 'POST',
410 'callback' => [$this, 'test_ai_connection'],
411 'permission_callback' => [$this, 'check_admin_permissions'],
412 'args' => [
413 'api_key' => [
414 'type' => 'string',
415 'required' => false,
416 'sanitize_callback' => 'sanitize_text_field',
417 ],
418 'provider' => [
419 'type' => 'string',
420 'required' => false,
421 'default' => 'openai',
422 'sanitize_callback' => 'sanitize_key',
423 ],
424 ],
425 ]);
426
427 register_rest_route(self::NAMESPACE, '/ai/providers', [
428 'methods' => 'GET',
429 'callback' => [$this, 'get_ai_providers'],
430 'permission_callback' => [$this, 'check_basic_permissions'],
431 ]);
432
433 // Register content brief endpoints
434 $content_brief_endpoint = new \ThinkRank\API\Content_Brief_Endpoint();
435 $content_brief_endpoint->register_routes();
436
437 // Register SEO score endpoints
438 try {
439 $database = new \ThinkRank\Core\Database();
440 $seo_calculator = new \ThinkRank\AI\SEOScoreCalculator($database);
441 $seo_score_endpoint = new \ThinkRank\API\SEOScoreEndpoint($seo_calculator);
442 $seo_score_endpoint->register_routes();
443 } catch (\Exception $e) {
444 // SEO Score endpoint registration failed
445 }
446
447 // Register Usage Analytics endpoints
448 try {
449 $usage_analytics_endpoint = new \ThinkRank\API\Usage_Analytics_Endpoint();
450 $usage_analytics_endpoint->register_routes();
451 } catch (\Exception $e) {
452 // Usage Analytics endpoint registration failed
453 }
454
455 // Register Site SEO Analyzer endpoint
456 try {
457 $seo_analyzer_endpoint = new \ThinkRank\API\SEO_Analyzer_Endpoint();
458 $seo_analyzer_endpoint->register_routes();
459 } catch (\Exception $e) {
460 // Site SEO Analyzer endpoint registration failed
461 }
462
463 // Register SEO Analytics endpoints
464 try {
465 $seo_analytics_endpoint = new \ThinkRank\API\SEO_Analytics_Endpoint();
466 $seo_analytics_endpoint->register_routes();
467 } catch (\Exception $e) {
468 // Failed to register SEO Analytics endpoint
469 }
470
471 // Register Instant Indexing endpoints
472 try {
473 $instant_indexing_endpoint = new \ThinkRank\API\Instant_Indexing_Endpoint();
474 $instant_indexing_endpoint->register_routes();
475 } catch (\Exception $e) {
476 // Failed to register Instant Indexing endpoint
477 }
478
479 // Register Pillar Content endpoints
480 try {
481 $pillar_content_endpoint = new \ThinkRank\API\Pillar_Content_Endpoint();
482 $pillar_content_endpoint->register_routes();
483 } catch (\Exception $e) {
484 // Failed to register Pillar Content endpoint
485 }
486
487 // Register Focus Keyword Usage endpoint ("already used" status).
488 try {
489 $focus_keyword_usage_endpoint = new \ThinkRank\API\Focus_Keyword_Usage_Endpoint();
490 $focus_keyword_usage_endpoint->register_routes();
491 } catch (\Exception $e) {
492 // Failed to register Focus Keyword Usage endpoint
493 }
494 // Register Global Robot Meta endpoints
495 try {
496 $global_robot_meta_endpoint = new \ThinkRank\API\Global_Robot_Meta_Endpoint();
497 $global_robot_meta_endpoint->register_routes();
498 } catch (\Exception $e) {
499 // Failed to register Global Robot Meta endpoint
500 }
501
502 // Register Author Archives endpoints
503 try {
504 $author_archives_endpoint = new \ThinkRank\API\Author_Archives_Endpoint();
505 $author_archives_endpoint->register_routes();
506 } catch (\Exception $e) {
507 // Failed to register Author Archives endpoint
508 }
509
510 // Register Role Manager endpoint
511 try {
512 $role_manager_endpoint = new \ThinkRank\API\Role_Manager_Endpoint();
513 $role_manager_endpoint->register_routes();
514 } catch (\Exception $e) {
515 // Failed to register Role Manager endpoint
516 }
517
518 // Register Email Report endpoints
519 try {
520 $email_report_endpoint = new \ThinkRank\API\Email_Report_Endpoint();
521 $email_report_endpoint->register_routes();
522 } catch (\Exception $e) {
523 // Failed to register Email Report endpoint
524 }
525
526
527 register_rest_route(self::NAMESPACE, '/ai/status', [
528 'methods' => 'GET',
529 'callback' => [$this, 'get_ai_status'],
530 'permission_callback' => [$this, 'check_basic_permissions'],
531 ]);
532
533 register_rest_route(self::NAMESPACE, '/ai/analyze-content', [
534 'methods' => 'POST',
535 'callback' => [$this, 'analyze_content'],
536 'permission_callback' => [$this, 'check_basic_permissions'],
537 'args' => [
538 'content' => [
539 'type' => 'string',
540 'required' => true,
541 'sanitize_callback' => [$this, 'sanitize_ai_content'],
542 ],
543 'metadata' => [
544 'type' => 'object',
545 'required' => false,
546 'sanitize_callback' => [$this, 'sanitize_metadata_object'],
547 ],
548 'post_id' => [
549 'type' => 'integer',
550 'required' => false,
551 'sanitize_callback' => 'absint',
552 ],
553 ],
554 ]);
555 }
556
557 /**
558 * Check basic permissions (for logged-in users)
559 *
560 * @param \WP_REST_Request $request Request object
561 * @return bool|WP_Error Permission status
562 */
563 public function check_basic_permissions(\WP_REST_Request $request) {
564 // Allow access for logged-in users who can edit posts
565 if (!is_user_logged_in()) {
566 return new \WP_Error(
567 'rest_forbidden',
568 __('You must be logged in to access this endpoint.', 'thinkrank'),
569 ['status' => 401]
570 );
571 }
572
573 if (!current_user_can('edit_posts')) {
574 return new \WP_Error(
575 'rest_forbidden',
576 __('You do not have permission to access this endpoint.', 'thinkrank'),
577 ['status' => 403]
578 );
579 }
580
581 return true;
582 }
583
584
585 /**
586 * Simple transient-based rate limiter
587 *
588 * @param string $bucket_id Unique bucket per user/IP and route
589 * @param int $limit Max requests per minute
590 * @return bool|\WP_Error True if allowed, or WP_Error when rate limited
591 */
592 private function enforce_rate_limit(string $bucket_id, int $limit) {
593 $settings = \ThinkRank\Core\Settings::instance();
594 $enabled = (bool) $settings->get('enable_rate_limiting', true);
595 if (!$enabled) {
596 return true;
597 }
598 $now = time();
599 $window = 60;
600 $key = 'thinkrank_rl_' . md5($bucket_id);
601 $bucket = get_transient($key);
602 if (!is_array($bucket)) {
603 $bucket = ['start' => $now, 'count' => 0];
604 }
605 if ($now - ($bucket['start'] ?? 0) >= $window) {
606 $bucket = ['start' => $now, 'count' => 0];
607 }
608 if (($bucket['count'] ?? 0) >= max(1, $limit)) {
609 return new \WP_Error('rate_limited', __('Rate limit exceeded. Please wait a moment and try again.', 'thinkrank'), ['status' => 429]);
610 }
611 $bucket['count']++;
612 set_transient($key, $bucket, $window);
613 return true;
614 }
615
616 /**
617 * Check admin permissions (for settings)
618 *
619 * @param \WP_REST_Request $request Request object
620 * @return bool|WP_Error Permission status
621 */
622 public function check_admin_permissions(\WP_REST_Request $request) {
623 // Allow access for administrators only
624 if (!is_user_logged_in()) {
625 return new \WP_Error(
626 'rest_forbidden',
627 __('You must be logged in to access this endpoint.', 'thinkrank'),
628 ['status' => 401]
629 );
630 }
631
632 if (!current_user_can('manage_options')) {
633 return new \WP_Error(
634 'rest_forbidden',
635 __('You do not have permission to manage settings.', 'thinkrank'),
636 ['status' => 403]
637 );
638 }
639
640 return true;
641 }
642
643 /**
644 * Check Settings section permissions.
645 *
646 * Delegable via Role Manager: passes for administrators (bypass) and for
647 * any role granted the `thinkrank_settings` capability. Used by the core
648 * /settings routes so the "Settings & API Keys" area can be delegated.
649 *
650 * @param \WP_REST_Request $request Request object
651 * @return bool|\WP_Error Permission status
652 */
653 public function check_settings_permissions(\WP_REST_Request $request) {
654 if (!is_user_logged_in()) {
655 return new \WP_Error(
656 'rest_forbidden',
657 __('You must be logged in to access this endpoint.', 'thinkrank'),
658 ['status' => 401]
659 );
660 }
661
662 if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings')) {
663 return new \WP_Error(
664 'rest_forbidden',
665 __('You do not have permission to manage ThinkRank settings.', 'thinkrank'),
666 ['status' => 403]
667 );
668 }
669
670 return true;
671 }
672
673 /**
674 * Get user capabilities
675 *
676 * @param \WP_REST_Request $request Request object
677 * @return \WP_REST_Response Response object
678 */
679 public function get_capabilities(\WP_REST_Request $request): \WP_REST_Response {
680 return new \WP_REST_Response([
681 'manage_settings' => current_user_can('manage_options'),
682 'view_analytics' => current_user_can('edit_posts'),
683
684 'use_ai_features' => current_user_can('edit_posts'),
685 ]);
686 }
687
688 /**
689 * Get plugin information
690 *
691 * @param \WP_REST_Request $request Request object
692 * @return \WP_REST_Response Response object
693 */
694 public function get_plugin_info(\WP_REST_Request $request): \WP_REST_Response {
695 return new \WP_REST_Response([
696 'version' => THINKRANK_VERSION,
697 'name' => 'ThinkRank',
698 'description' => 'AI-native SEO plugin for WordPress',
699 ]);
700 }
701
702 /**
703 * Get system status
704 *
705 * @param \WP_REST_Request $request Request object
706 * @return \WP_REST_Response Response object
707 */
708 public function get_system_status(\WP_REST_Request $request): \WP_REST_Response {
709 return new \WP_REST_Response([
710 'status' => 'healthy',
711 'issues' => [],
712 'php_version' => PHP_VERSION,
713 'wp_version' => get_bloginfo('version'),
714 ]);
715 }
716
717 /**
718 * Get ThinkRank integration health for MCP/Abilities clients (see #188).
719 *
720 * Admin-gated diagnostic; never returns secret material. Delegates to the
721 * shared reporter so the ability and this route stay in lock-step.
722 *
723 * @param \WP_REST_Request $request Request object
724 * @return \WP_REST_Response Response object
725 */
726 public function get_connection_status(\WP_REST_Request $request): \WP_REST_Response {
727 return new \WP_REST_Response(\ThinkRank\Diagnostics\Connection_Status::report());
728 }
729
730
731
732 /**
733 * Get settings
734 *
735 * @param \WP_REST_Request $request Request object
736 * @return \WP_REST_Response Response object
737 */
738 public function get_settings(\WP_REST_Request $request): \WP_REST_Response {
739 // Use Settings class for consistent access (handles decryption automatically)
740 $settings_instance = \ThinkRank\Core\Settings::instance();
741
742 $settings = [
743 'ai_provider' => $settings_instance->get('ai_provider', 'openai'),
744 'openai_api_key' => $settings_instance->get('openai_api_key', ''),
745 'openai_model' => $settings_instance->get('openai_model', 'gpt-5-nano'),
746 'claude_api_key' => $settings_instance->get('claude_api_key', ''),
747 'claude_model' => $settings_instance->get('claude_model', 'claude-sonnet-5'),
748 'gemini_api_key' => $settings_instance->get('gemini_api_key', ''),
749 'gemini_model' => $settings_instance->get('gemini_model', 'gemini-2.5-flash'),
750 'openrouter_api_key' => $settings_instance->get('openrouter_api_key', ''),
751 'openrouter_model' => $settings_instance->get('openrouter_model', 'openai/gpt-4o-mini'),
752 'max_tokens' => $settings_instance->get('max_tokens', 1000),
753 'temperature' => $settings_instance->get('temperature', 0.7),
754 'cache_duration' => $settings_instance->get('cache_duration', 3600),
755 'keep_data_on_uninstall' => (bool) $settings_instance->get('keep_data_on_uninstall', true),
756 'enable_migration_tools' => (bool) $settings_instance->get('enable_migration_tools', false),
757 'google_account_connected' => (bool) $settings_instance->get('google_account_connected', false),
758 'enable_mcp' => (bool) $settings_instance->get('enable_mcp', false),
759 ];
760
761
762
763 // Don't send full API keys to frontend for security - mask them,
764 // revealing the first 5 and last 3 chars so the saved key is recognizable.
765 if (!empty($settings['openai_api_key'])) {
766 $settings['openai_api_key'] = $this->mask_ai_api_key($settings['openai_api_key']);
767 }
768 if (!empty($settings['claude_api_key'])) {
769 $settings['claude_api_key'] = $this->mask_ai_api_key($settings['claude_api_key']);
770 }
771 if (!empty($settings['gemini_api_key'])) {
772 $settings['gemini_api_key'] = $this->mask_ai_api_key($settings['gemini_api_key']);
773 }
774 if (!empty($settings['openrouter_api_key'])) {
775 $settings['openrouter_api_key'] = $this->mask_ai_api_key($settings['openrouter_api_key']);
776 }
777
778 return new \WP_REST_Response($settings);
779 }
780
781 /**
782 * Mask an AI provider API key for display.
783 *
784 * Reveals the first 5 and last 3 characters with a bullet run in between
785 * (e.g. "sk-pr••••••••abc"). Keys of 8 chars or fewer are fully masked so
786 * head + tail can't reconstruct the whole value. The "••••••••" sentinel is
787 * what save_settings() looks for to skip re-saving a resubmitted mask.
788 *
789 * @param string $key Raw API key.
790 * @return string Masked key safe to send to the frontend.
791 */
792 private function mask_ai_api_key(string $key): string {
793 if (strlen($key) <= 8) {
794 return '••••••••';
795 }
796
797 return substr($key, 0, 5) . '••••••••' . substr($key, -3);
798 }
799
800 /**
801 * Save settings
802 *
803 * @param \WP_REST_Request $request Request object
804 * @return \WP_REST_Response Response object
805 */
806 public function save_settings(\WP_REST_Request $request): \WP_REST_Response {
807 $params = $request->get_params();
808
809 // Get Settings instance for proper encryption handling
810 $settings = \ThinkRank\Core\Settings::instance();
811
812 // Map frontend parameter names to setting keys
813 $settings_map = [
814 'ai_provider' => 'ai_provider',
815 'openai_api_key' => 'openai_api_key',
816 'openai_model' => 'openai_model',
817 'claude_api_key' => 'claude_api_key',
818 'claude_model' => 'claude_model',
819 'gemini_api_key' => 'gemini_api_key',
820 'gemini_model' => 'gemini_model',
821 'openrouter_api_key' => 'openrouter_api_key',
822 'openrouter_model' => 'openrouter_model',
823 'max_tokens' => 'max_tokens',
824 'temperature' => 'temperature',
825 'cache_duration' => 'cache_duration',
826 'keep_data_on_uninstall' => 'keep_data_on_uninstall',
827 'enable_mcp' => 'enable_mcp',
828 'enable_migration_tools' => 'enable_migration_tools',
829 ];
830
831 // Processing settings save request
832
833 foreach ($settings_map as $param_key => $setting_key) {
834 if (isset($params[$param_key])) {
835 $value = $params[$param_key];
836
837 // Handle API keys specially - check for masked values
838 if (in_array($param_key, ['openai_api_key', 'claude_api_key', 'gemini_api_key', 'openrouter_api_key'])) {
839 // Don't update if the value carries the mask sentinel (the
840 // preview now keeps real head/tail chars around it, so match
841 // anywhere rather than only at the start). Empty still clears.
842 if (strpos($value, '••••••••') !== false) {
843 continue;
844 }
845 }
846
847 // Use Settings class for all operations (handles encryption automatically)
848 if (!$settings->set($setting_key, $value)) {
849 // Settings save failed, continue with other settings
850 }
851 }
852 }
853
854 // Auto-dismiss welcome notice if API key was saved
855 $this->maybe_dismiss_welcome_notice($params);
856
857 // Force AI Manager to re-initialize client with new settings
858 if (isset($params['ai_provider']) || isset($params['openai_api_key']) || isset($params['claude_api_key']) || isset($params['gemini_api_key']) || isset($params['openrouter_api_key'])) {
859 // Clear any cached AI Manager instances to force re-initialization
860 wp_cache_delete('thinkrank_ai_manager', 'thinkrank');
861
862 // If we have an AI Manager instance, force it to re-initialize
863 try {
864 $ai_manager = new \ThinkRank\AI\Manager($settings);
865 $ai_manager->reinitialize_client();
866 } catch (\Exception $e) {
867 // Ignore initialization errors at this point
868 }
869 }
870
871 return new \WP_REST_Response([
872 'success' => true,
873 'message' => __('Settings saved successfully', 'thinkrank'),
874 'settings' => $this->get_settings($request)->get_data(),
875 ]);
876 }
877
878 /**
879 * Maybe dismiss welcome notice if API key was saved
880 *
881 * @param array $params Request parameters
882 * @return void
883 */
884 private function maybe_dismiss_welcome_notice(array $params): void {
885 // Check if an API key was saved (not cleared)
886 $api_key_saved = false;
887
888 if (!empty($params['openai_api_key']) && $params['openai_api_key'] !== '') {
889 $api_key_saved = true;
890 }
891
892 if (!empty($params['claude_api_key']) && $params['claude_api_key'] !== '') {
893 $api_key_saved = true;
894 }
895
896 // Auto-dismiss welcome notice if API key was configured
897 if ($api_key_saved && get_option('thinkrank_show_welcome')) {
898 delete_option('thinkrank_show_welcome');
899 }
900 }
901
902 /**
903 * Get metadata for post
904 *
905 * @param \WP_REST_Request $request Request object
906 * @return \WP_REST_Response Response object
907 */
908 public function get_metadata(\WP_REST_Request $request): \WP_REST_Response {
909 $post_id = (int) $request->get_param('post_id');
910
911 // Object-level guard: only expose a post's stored SEO meta to a user who
912 // can edit that specific post (the section capability gate handles the
913 // AI Tools toggle; this adds per-post ownership).
914 if (!current_user_can('edit_post', $post_id)) {
915 return new \WP_REST_Response(['message' => 'You are not allowed to view this metadata.'], 403);
916 }
917
918 // Get existing metadata
919 $metadata = [
920 'title' => get_post_meta($post_id, '_thinkrank_title', true),
921 'description' => get_post_meta($post_id, '_thinkrank_description', true),
922 'keywords' => get_post_meta($post_id, '_thinkrank_keywords', true),
923 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true) ?: 0,
924 'last_generated' => get_post_meta($post_id, '_thinkrank_last_generated', true),
925 ];
926
927 return new \WP_REST_Response($metadata);
928 }
929
930 /**
931 * Generate AI-powered SEO metadata
932 *
933 * @param \WP_REST_Request $request Request object containing content and generation options
934 * @return \WP_REST_Response Response object with generated metadata or error message
935 * @throws \Exception When AI metadata generation fails or AI client is unavailable
936 */
937 public function generate_ai_metadata(\WP_REST_Request $request): \WP_REST_Response {
938 $content = $request->get_param('content');
939 $options = [
940 'target_keyword' => $request->get_param('target_keyword'),
941 'content_type' => $request->get_param('content_type'),
942 'tone' => $request->get_param('tone'),
943 ];
944
945 // Rate limiting: per user/IP per route
946 $user_id = get_current_user_id();
947 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
948 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
949 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
950 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
951 if (is_wp_error($allowed)) {
952 return new \WP_REST_Response([
953 'success' => false,
954 'message' => $allowed->get_error_message(),
955 ], $allowed->get_error_data()['status'] ?? 429);
956 }
957
958 try {
959 // Get AI manager instance
960 $ai_manager = new \ThinkRank\AI\Manager();
961 $ai_manager->initialize_client();
962
963 // Run through the generator so title/description are capped to the
964 // configured limits and the character counts are returned.
965 $generator = new \ThinkRank\AI\Metadata_Generator($ai_manager);
966 $metadata = $generator->generate_for_content($content, $options);
967
968 return new \WP_REST_Response([
969 'success' => true,
970 'data' => $metadata,
971 'message' => __('SEO metadata generated successfully', 'thinkrank'),
972 ]);
973 } catch (\Exception $e) {
974 return new \WP_REST_Response([
975 'success' => false,
976 'message' => $e->getMessage(),
977 ], 400);
978 }
979 }
980
981 /**
982 * Generate and return an improved SEO title for an "Apply" suggestion action.
983 *
984 * @param \WP_REST_Request $request Request object.
985 * @return \WP_REST_Response Response with the improved title under data.title.
986 * @throws \Exception When title improvement fails or the AI client is unavailable.
987 */
988 public function improve_ai_title(\WP_REST_Request $request): \WP_REST_Response {
989 $content = $request->get_param('content');
990 $options = [
991 'current_title' => $request->get_param('current_title'),
992 'target_keyword' => $request->get_param('target_keyword'),
993 'content_type' => $request->get_param('content_type'),
994 'tone' => $request->get_param('tone'),
995 'suggestion' => $request->get_param('suggestion'),
996 ];
997
998 // Rate limiting: shares the AI generation bucket.
999 $user_id = get_current_user_id();
1000 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1001 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1002 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1003 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1004 if (is_wp_error($allowed)) {
1005 return new \WP_REST_Response([
1006 'success' => false,
1007 'message' => $allowed->get_error_message(),
1008 ], $allowed->get_error_data()['status'] ?? 429);
1009 }
1010
1011 try {
1012 $ai_manager = new \ThinkRank\AI\Manager();
1013 $ai_manager->initialize_client();
1014
1015 $result = $ai_manager->improve_seo_title($content, $options);
1016
1017 return new \WP_REST_Response([
1018 'success' => true,
1019 'data' => $result,
1020 'message' => __('SEO title improved successfully', 'thinkrank'),
1021 ]);
1022 } catch (\Exception $e) {
1023 return new \WP_REST_Response([
1024 'success' => false,
1025 'message' => $e->getMessage(),
1026 ], 400);
1027 }
1028 }
1029
1030 /**
1031 * Generate and return an improved meta description for an "Apply" action.
1032 *
1033 * @param \WP_REST_Request $request Request object.
1034 * @return \WP_REST_Response Response with the description under data.description.
1035 * @throws \Exception When generation fails or the AI client is unavailable.
1036 */
1037 public function improve_ai_meta_description(\WP_REST_Request $request): \WP_REST_Response {
1038 $content = $request->get_param('content');
1039 $options = [
1040 'current_description' => $request->get_param('current_description'),
1041 'target_keyword' => $request->get_param('target_keyword'),
1042 'content_type' => $request->get_param('content_type'),
1043 'tone' => $request->get_param('tone'),
1044 'suggestion' => $request->get_param('suggestion'),
1045 ];
1046
1047 // Rate limiting: shares the AI generation bucket.
1048 $user_id = get_current_user_id();
1049 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1050 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1051 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1052 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1053 if (is_wp_error($allowed)) {
1054 return new \WP_REST_Response([
1055 'success' => false,
1056 'message' => $allowed->get_error_message(),
1057 ], $allowed->get_error_data()['status'] ?? 429);
1058 }
1059
1060 try {
1061 $ai_manager = new \ThinkRank\AI\Manager();
1062 $ai_manager->initialize_client();
1063
1064 $result = $ai_manager->improve_meta_description($content, $options);
1065
1066 return new \WP_REST_Response([
1067 'success' => true,
1068 'data' => $result,
1069 'message' => __('Meta description generated successfully', 'thinkrank'),
1070 ]);
1071 } catch (\Exception $e) {
1072 return new \WP_REST_Response([
1073 'success' => false,
1074 'message' => $e->getMessage(),
1075 ], 400);
1076 }
1077 }
1078
1079 /**
1080 * Explain a single SEO suggestion in plain, post-specific language.
1081 *
1082 * Read-only copilot action: returns a short AI explanation of why the
1083 * suggestion matters for this post and how to resolve it. Does not modify
1084 * any content.
1085 *
1086 * @param \WP_REST_Request $request Request object.
1087 * @return \WP_REST_Response Response with the explanation under data.explanation.
1088 * @throws \Exception When generation fails or the AI client is unavailable.
1089 */
1090 public function explain_ai_suggestion(\WP_REST_Request $request): \WP_REST_Response {
1091 $content = $request->get_param('content');
1092 $options = [
1093 'suggestion' => $request->get_param('suggestion'),
1094 'title' => $request->get_param('title'),
1095 'target_keyword' => $request->get_param('target_keyword'),
1096 'content_type' => $request->get_param('content_type'),
1097 ];
1098
1099 // Rate limiting: shares the AI generation bucket.
1100 $user_id = get_current_user_id();
1101 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1102 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1103 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1104 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1105 if (is_wp_error($allowed)) {
1106 return new \WP_REST_Response([
1107 'success' => false,
1108 'message' => $allowed->get_error_message(),
1109 ], $allowed->get_error_data()['status'] ?? 429);
1110 }
1111
1112 try {
1113 $ai_manager = new \ThinkRank\AI\Manager();
1114 $ai_manager->initialize_client();
1115
1116 $result = $ai_manager->explain_seo_suggestion($content, $options);
1117
1118 return new \WP_REST_Response([
1119 'success' => true,
1120 'data' => $result,
1121 'message' => __('Explanation generated successfully', 'thinkrank'),
1122 ]);
1123 } catch (\Exception $e) {
1124 return new \WP_REST_Response([
1125 'success' => false,
1126 'message' => $e->getMessage(),
1127 ], 400);
1128 }
1129 }
1130
1131 /**
1132 * Generate a content fragment with one authoritative external dofollow link.
1133 *
1134 * @param \WP_REST_Request $request Request object.
1135 * @return \WP_REST_Response Response with the HTML fragment under data.html.
1136 * @throws \Exception When generation fails or the AI client is unavailable.
1137 */
1138 public function add_ai_dofollow_link(\WP_REST_Request $request): \WP_REST_Response {
1139 $content = $request->get_param('content');
1140 $options = [
1141 'target_keyword' => $request->get_param('target_keyword'),
1142 'content_type' => $request->get_param('content_type'),
1143 ];
1144
1145 // Rate limiting: shares the AI generation bucket.
1146 $user_id = get_current_user_id();
1147 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1148 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1149 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1150 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1151 if (is_wp_error($allowed)) {
1152 return new \WP_REST_Response([
1153 'success' => false,
1154 'message' => $allowed->get_error_message(),
1155 ], $allowed->get_error_data()['status'] ?? 429);
1156 }
1157
1158 try {
1159 $ai_manager = new \ThinkRank\AI\Manager();
1160 $ai_manager->initialize_client();
1161
1162 $result = $ai_manager->generate_dofollow_link($content, $options);
1163
1164 return new \WP_REST_Response([
1165 'success' => true,
1166 'data' => $result,
1167 'message' => __('Added an authoritative source link', 'thinkrank'),
1168 ]);
1169 } catch (\Exception $e) {
1170 return new \WP_REST_Response([
1171 'success' => false,
1172 'message' => $e->getMessage(),
1173 ], 400);
1174 }
1175 }
1176
1177 /**
1178 * Generate a keyword-rich paragraph to lift keyword density into band.
1179 *
1180 * @param \WP_REST_Request $request Request object.
1181 * @return \WP_REST_Response Response with the HTML fragment under data.html.
1182 * @throws \Exception When generation fails or the AI client is unavailable.
1183 */
1184 public function add_ai_keyword_paragraph(\WP_REST_Request $request): \WP_REST_Response {
1185 $content = $request->get_param('content');
1186 $options = [
1187 'target_keyword' => $request->get_param('target_keyword'),
1188 'content_type' => $request->get_param('content_type'),
1189 'tone' => $request->get_param('tone'),
1190 'word_count' => $request->get_param('word_count'),
1191 'keyword_count' => $request->get_param('keyword_count'),
1192 ];
1193
1194 // Rate limiting: shares the AI generation bucket.
1195 $user_id = get_current_user_id();
1196 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1197 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1198 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1199 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1200 if (is_wp_error($allowed)) {
1201 return new \WP_REST_Response([
1202 'success' => false,
1203 'message' => $allowed->get_error_message(),
1204 ], $allowed->get_error_data()['status'] ?? 429);
1205 }
1206
1207 try {
1208 $ai_manager = new \ThinkRank\AI\Manager();
1209 $ai_manager->initialize_client();
1210
1211 $result = $ai_manager->generate_keyword_paragraph($content, $options);
1212
1213 return new \WP_REST_Response([
1214 'success' => true,
1215 'data' => $result,
1216 'message' => __('Added a keyword-focused paragraph', 'thinkrank'),
1217 ]);
1218 } catch (\Exception $e) {
1219 return new \WP_REST_Response([
1220 'success' => false,
1221 'message' => $e->getMessage(),
1222 ], 400);
1223 }
1224 }
1225
1226 /**
1227 * Enable ThinkRank's Global SEO schema output for a post's post type.
1228 *
1229 * Sets a sensible default schema type (Article for posts, WebPage for pages)
1230 * when none is configured yet, so ThinkRank emits JSON-LD for the post. This
1231 * resolves the "add structured data" suggestion, which the scorer now credits
1232 * when ThinkRank schema is active.
1233 *
1234 * @param \WP_REST_Request $request Request object.
1235 * @return \WP_REST_Response Response describing the enabled schema type.
1236 */
1237 public function enable_schema_for_post(\WP_REST_Request $request): \WP_REST_Response {
1238 $post_id = (int) $request->get_param('post_id');
1239 $post = get_post($post_id);
1240 if (!$post) {
1241 return new \WP_REST_Response([
1242 'success' => false,
1243 'message' => __('Post not found.', 'thinkrank'),
1244 ], 404);
1245 }
1246
1247 $post_type = $post->post_type;
1248 $settings = get_option('thinkrank_global_seo_settings', []);
1249 if (!is_array($settings)) {
1250 $settings = [];
1251 }
1252 if (!isset($settings[$post_type]) || !is_array($settings[$post_type])) {
1253 $settings[$post_type] = [];
1254 }
1255
1256 $already_enabled = !empty($settings[$post_type]['schema_type']);
1257 if (!$already_enabled) {
1258 if ($post_type === 'page') {
1259 $settings[$post_type]['schema_type'] = 'WebPage';
1260 } else {
1261 $settings[$post_type]['schema_type'] = 'Article';
1262 if (empty($settings[$post_type]['article_type'])) {
1263 $settings[$post_type]['article_type'] = 'BlogPosting';
1264 }
1265 }
1266 update_option('thinkrank_global_seo_settings', $settings);
1267 }
1268
1269 $schema_type = $settings[$post_type]['schema_type'];
1270
1271 return new \WP_REST_Response([
1272 'success' => true,
1273 'data' => [
1274 'schema_type' => $schema_type,
1275 'post_type' => $post_type,
1276 'already_enabled' => $already_enabled,
1277 ],
1278 'message' => $already_enabled
1279 ? __('Schema was already enabled for this post type.', 'thinkrank')
1280 /* translators: %s: schema type. */
1281 : sprintf(__('Enabled %s schema for this post type.', 'thinkrank'), $schema_type),
1282 ]);
1283 }
1284
1285 /**
1286 * Test AI connection for specified provider
1287 *
1288 * @param \WP_REST_Request $request Request object containing api_key and provider parameters
1289 * @return \WP_REST_Response Response object with connection test results
1290 * @throws \Exception When API connection test encounters unexpected errors
1291 */
1292 public function test_ai_connection(\WP_REST_Request $request): \WP_REST_Response {
1293 // Rate limiting: per user/IP per route
1294 $user_id = get_current_user_id();
1295 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1296 $bucket_id = 'ai_test|' . ($user_id ?: $ip);
1297 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1298 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1299 if (is_wp_error($allowed)) {
1300 return new \WP_REST_Response([
1301 'success' => false,
1302 'message' => $allowed->get_error_message(),
1303 ], $allowed->get_error_data()['status'] ?? 429);
1304 }
1305
1306 try {
1307 $api_key = $request->get_param('api_key');
1308 $provider = $request->get_param('provider') ?: 'openai';
1309
1310 // If no API key provided in request, try to get from saved settings
1311 if (empty($api_key)) {
1312 $settings = \ThinkRank\Core\Settings::instance();
1313 if ($provider === 'openai') {
1314 $api_key = (string) $settings->get('openai_api_key', '');
1315 } elseif ($provider === 'claude') {
1316 $api_key = (string) $settings->get('claude_api_key', '');
1317 } elseif ($provider === 'openrouter') {
1318 $api_key = (string) $settings->get('openrouter_api_key', '');
1319 } else {
1320 $api_key = (string) $settings->get('gemini_api_key', '');
1321 }
1322
1323 if (empty($api_key)) {
1324 return new \WP_REST_Response([
1325 'success' => false,
1326 'message' => __('No API key provided or saved for the selected provider.', 'thinkrank'),
1327 ], 400);
1328 }
1329 }
1330
1331 // Test the connection with a simple API call
1332 if ($provider === 'openai') {
1333 $result = $this->test_openai_connection($api_key);
1334 } elseif ($provider === 'claude') {
1335 $result = $this->test_claude_connection($api_key);
1336 } elseif ($provider === 'openrouter') {
1337 $result = $this->test_openrouter_connection($api_key);
1338 } else {
1339 $result = $this->test_gemini_connection($api_key);
1340 }
1341
1342 return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
1343 } catch (\Exception $e) {
1344 return new \WP_REST_Response([
1345 'success' => false,
1346 'message' => $e->getMessage(),
1347 ], 500);
1348 }
1349 }
1350
1351 /**
1352 * Test OpenAI API connection
1353 *
1354 * @param string $api_key API key to test
1355 * @return array Test result
1356 */
1357 private function test_openai_connection(string $api_key): array {
1358 $url = 'https://api.openai.com/v1/models';
1359
1360 $response = wp_remote_get($url, [
1361 'headers' => [
1362 'Authorization' => 'Bearer ' . $api_key,
1363 'Content-Type' => 'application/json',
1364 ],
1365 'timeout' => 10,
1366 ]);
1367
1368 if (is_wp_error($response)) {
1369 return [
1370 'success' => false,
1371 'message' => __('Failed to connect to OpenAI API: ', 'thinkrank') . $response->get_error_message(),
1372 ];
1373 }
1374
1375 $status_code = wp_remote_retrieve_response_code($response);
1376 $body = wp_remote_retrieve_body($response);
1377
1378 if ($status_code === 200) {
1379 $data = json_decode($body, true);
1380 if (isset($data['data']) && is_array($data['data'])) {
1381 return [
1382 'success' => true,
1383 'message' => __('OpenAI API connection successful!', 'thinkrank'),
1384 'models_count' => count($data['data']),
1385 ];
1386 }
1387 }
1388
1389 // Handle error response
1390 $error_data = json_decode($body, true);
1391 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1392
1393 return [
1394 'success' => false,
1395 'message' => __('OpenAI API Error: ', 'thinkrank') . $error_message,
1396 ];
1397 }
1398
1399 /**
1400 * Test OpenRouter API connection
1401 *
1402 * @param string $api_key API key to test
1403 * @return array Test result
1404 */
1405 private function test_openrouter_connection(string $api_key): array {
1406 // Validate the key format first (OpenRouter keys start with "sk-or-").
1407 if (!str_starts_with($api_key, 'sk-or-')) {
1408 return [
1409 'success' => false,
1410 'message' => __('Invalid OpenRouter API key format. Should start with "sk-or-"', 'thinkrank'),
1411 ];
1412 }
1413
1414 // The key endpoint validates the credential and returns its metadata.
1415 $url = 'https://openrouter.ai/api/v1/key';
1416
1417 $response = wp_remote_get($url, [
1418 'headers' => [
1419 'Authorization' => 'Bearer ' . $api_key,
1420 'Content-Type' => 'application/json',
1421 'HTTP-Referer' => home_url('/'),
1422 'X-Title' => 'ThinkRank',
1423 ],
1424 'timeout' => 10,
1425 ]);
1426
1427 if (is_wp_error($response)) {
1428 return [
1429 'success' => false,
1430 'message' => __('Failed to connect to OpenRouter API: ', 'thinkrank') . $response->get_error_message(),
1431 ];
1432 }
1433
1434 $status_code = wp_remote_retrieve_response_code($response);
1435 $body = wp_remote_retrieve_body($response);
1436
1437 if ($status_code === 200) {
1438 $data = json_decode($body, true);
1439 if (isset($data['data']) && is_array($data['data'])) {
1440 return [
1441 'success' => true,
1442 'message' => __('OpenRouter API connection successful!', 'thinkrank'),
1443 ];
1444 }
1445 }
1446
1447 // Handle error response
1448 $error_data = json_decode($body, true);
1449 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1450
1451 return [
1452 'success' => false,
1453 'message' => __('OpenRouter API Error: ', 'thinkrank') . $error_message,
1454 ];
1455 }
1456
1457 /**
1458 * Test Claude API connection
1459 *
1460 * @param string $api_key API key to test
1461 * @return array Test result
1462 */
1463 private function test_claude_connection(string $api_key): array {
1464 // First validate the key format
1465 if (!str_starts_with($api_key, 'sk-ant-')) {
1466 return [
1467 'success' => false,
1468 'message' => __('Invalid Claude API key format. Should start with "sk-ant-"', 'thinkrank'),
1469 ];
1470 }
1471
1472 // Test with a simple API call
1473 $url = 'https://api.anthropic.com/v1/messages';
1474
1475 // Get the configured Claude model, with fallback to a current model.
1476 // Self-heal retired/unavailable IDs saved by earlier versions.
1477 $claude_model = \ThinkRank\Core\Settings::instance()->get('claude_model', 'claude-sonnet-5');
1478 $claude_model = \ThinkRank\AI\Claude_Client::normalize_model($claude_model);
1479
1480 $body = [
1481 'model' => $claude_model,
1482 'max_tokens' => 10,
1483 'messages' => [
1484 [
1485 'role' => 'user',
1486 'content' => 'Hello'
1487 ]
1488 ]
1489 ];
1490
1491 $response = wp_remote_post($url, [
1492 'headers' => [
1493 'x-api-key' => $api_key,
1494 'Content-Type' => 'application/json',
1495 'anthropic-version' => '2023-06-01',
1496 ],
1497 'body' => wp_json_encode($body),
1498 'timeout' => 10,
1499 ]);
1500
1501 if (is_wp_error($response)) {
1502 return [
1503 'success' => false,
1504 'message' => __('Failed to connect to Claude API: ', 'thinkrank') . $response->get_error_message(),
1505 ];
1506 }
1507
1508 $status_code = wp_remote_retrieve_response_code($response);
1509 $response_body = wp_remote_retrieve_body($response);
1510
1511 if ($status_code === 200) {
1512 return [
1513 'success' => true,
1514 'message' => __('Claude API connection successful!', 'thinkrank'),
1515 ];
1516 } else {
1517 $error_data = json_decode($response_body, true);
1518 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1519
1520 return [
1521 'success' => false,
1522 /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */
1523 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1524 ];
1525 }
1526 }
1527
1528 /**
1529 * Test Gemini API connection
1530 *
1531 * @param string $api_key API key to test
1532 * @return array Test result
1533 */
1534 private function test_gemini_connection(string $api_key): array {
1535 // Test with a simple API call
1536 $gemini_model = \ThinkRank\Core\Settings::instance()->get('gemini_model', 'gemini-2.5-flash');
1537 $url = "https://generativelanguage.googleapis.com/v1beta/models/{$gemini_model}:generateContent?key={$api_key}";
1538
1539 $body = [
1540 'contents' => [
1541 [
1542 'parts' => [
1543 ['text' => 'Hello']
1544 ]
1545 ]
1546 ],
1547 'generationConfig' => [
1548 'maxOutputTokens' => 10,
1549 'temperature' => 0.1,
1550 ]
1551 ];
1552
1553 $response = wp_remote_post($url, [
1554 'headers' => [
1555 'Content-Type' => 'application/json',
1556 ],
1557 'body' => wp_json_encode($body),
1558 'timeout' => 10,
1559 ]);
1560
1561 if (is_wp_error($response)) {
1562 return [
1563 'success' => false,
1564 'message' => __('Failed to connect to Gemini API: ', 'thinkrank') . $response->get_error_message(),
1565 ];
1566 }
1567
1568 $status_code = wp_remote_retrieve_response_code($response);
1569 $response_body = wp_remote_retrieve_body($response);
1570
1571 if ($status_code === 200) {
1572 return [
1573 'success' => true,
1574 'message' => __('Gemini API connection successful!', 'thinkrank'),
1575 ];
1576 } else {
1577 $error_data = json_decode($response_body, true);
1578 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1579
1580 return [
1581 'success' => false,
1582 /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */
1583 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1584 ];
1585 }
1586 }
1587
1588 /**
1589 * Get AI providers
1590 *
1591 * @param \WP_REST_Request $request Request object
1592 * @return \WP_REST_Response Response object
1593 */
1594 public function get_ai_providers(\WP_REST_Request $request): \WP_REST_Response {
1595 $ai_manager = new \ThinkRank\AI\Manager();
1596 $providers = $ai_manager->get_available_providers();
1597
1598 return new \WP_REST_Response($providers);
1599 }
1600
1601 /**
1602 * Get AI status
1603 *
1604 * @param \WP_REST_Request $request Request object
1605 * @return \WP_REST_Response Response object
1606 */
1607 public function get_ai_status(\WP_REST_Request $request): \WP_REST_Response {
1608 $ai_manager = new \ThinkRank\AI\Manager();
1609 $status = $ai_manager->get_provider_status();
1610
1611 return new \WP_REST_Response($status);
1612 }
1613
1614 /**
1615 * Analyze content for SEO optimization
1616 *
1617 * @param \WP_REST_Request $request Request object containing content, metadata, and optional post_id
1618 * @return \WP_REST_Response Response object with analysis results or error message
1619 * @throws \Exception When AI analysis fails or AI client initialization fails
1620 */
1621 public function analyze_content(\WP_REST_Request $request): \WP_REST_Response {
1622 $content = $request->get_param('content');
1623 $metadata = $request->get_param('metadata') ?: [];
1624 $post_id = $request->get_param('post_id');
1625
1626 // Rate limiting: per user/IP per route
1627 $user_id = get_current_user_id();
1628 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1629 $bucket_id = 'ai_analyze|' . ($user_id ?: $ip);
1630 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1631 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1632 if (is_wp_error($allowed)) {
1633 return new \WP_REST_Response([
1634 'success' => false,
1635 'message' => $allowed->get_error_message(),
1636 ], $allowed->get_error_data()['status'] ?? 429);
1637 }
1638
1639 try {
1640 // Get AI manager instance
1641 $ai_manager = new \ThinkRank\AI\Manager();
1642 $ai_manager->initialize_client();
1643
1644 // Perform content analysis
1645 $analysis = $ai_manager->analyze_content($content, $metadata);
1646
1647 return new \WP_REST_Response([
1648 'success' => true,
1649 'data' => $analysis,
1650 'message' => __('Content analyzed successfully', 'thinkrank'),
1651 ]);
1652 } catch (\Exception $e) {
1653 return new \WP_REST_Response([
1654 'success' => false,
1655 'message' => $e->getMessage(),
1656 ], 400);
1657 }
1658 }
1659
1660 /**
1661 * Sanitize metadata object for API endpoints
1662 *
1663 * @param mixed $metadata Metadata to sanitize
1664 * @return array Sanitized metadata array
1665 */
1666 public function sanitize_metadata_object($metadata): array {
1667 if (!is_array($metadata)) {
1668 return [];
1669 }
1670
1671 $sanitized = [];
1672 foreach ($metadata as $key => $value) {
1673 $sanitized_key = sanitize_key($key);
1674
1675 if (is_string($value)) {
1676 $sanitized[$sanitized_key] = sanitize_text_field($value);
1677 } elseif (is_array($value)) {
1678 // Recursively sanitize nested arrays
1679 $sanitized[$sanitized_key] = array_map('sanitize_text_field', $value);
1680 } elseif (is_numeric($value)) {
1681 $sanitized[$sanitized_key] = (float) $value;
1682 } elseif (is_bool($value)) {
1683 $sanitized[$sanitized_key] = (bool) $value;
1684 }
1685 // Skip other data types for security
1686 }
1687
1688 return $sanitized;
1689 }
1690
1691 /**
1692 * Register endpoint classes
1693 *
1694 * @return void
1695 */
1696 public function register_endpoint_classes(): void {
1697 // Register endpoint classes that exist
1698 try {
1699 $site_identity_endpoint = new Site_Identity_Endpoint();
1700 $site_identity_endpoint->register_routes();
1701 } catch (\Exception $e) {
1702 // Failed to register Site Identity endpoint
1703 }
1704
1705 try {
1706 $performance_endpoint = new Performance_Endpoint();
1707 $performance_endpoint->register_routes();
1708 } catch (\Exception $e) {
1709 // Failed to register Performance endpoint
1710 }
1711
1712 try {
1713 $schema_endpoint = new Schema_Endpoint();
1714 $schema_endpoint->register_routes();
1715 } catch (\Exception $e) {
1716 // Failed to register Schema endpoint
1717 }
1718
1719 try {
1720 $settings_endpoint = new Settings_Management_Endpoint();
1721 $settings_endpoint->register_routes();
1722 } catch (\Exception $e) {
1723 // Failed to register Settings Management endpoint
1724 }
1725
1726 try {
1727 $integrations_endpoint = new Integrations_Endpoint();
1728 $integrations_endpoint->register_routes();
1729 } catch (\Exception $e) {
1730 // Failed to register Integrations endpoint
1731 }
1732
1733 try {
1734 $social_platforms_endpoint = new Social_Platforms_Endpoint();
1735 $social_platforms_endpoint->register_routes();
1736 } catch (\Exception $e) {
1737 // Failed to register Social Platforms endpoint
1738 }
1739
1740 try {
1741 $content_brief_endpoint = new Content_Brief_Endpoint();
1742 $content_brief_endpoint->register_routes();
1743 } catch (\Exception $e) {
1744 // Failed to register Content Brief endpoint
1745 }
1746
1747 try {
1748 $social_media_endpoint = new Social_Media_Endpoint();
1749 $social_media_endpoint->register_routes();
1750 } catch (\Exception $e) {
1751 // Failed to register Social Media endpoint
1752 }
1753
1754 try {
1755 $sitemap_endpoint = new Sitemap_Endpoint();
1756 $sitemap_endpoint->register_routes();
1757 } catch (\Exception $e) {
1758 // Failed to register Sitemap endpoint
1759 }
1760
1761 try {
1762 $llms_txt_endpoint = new LLMs_Txt_Endpoint();
1763 $llms_txt_endpoint->register_routes();
1764 } catch (\Exception $e) {
1765 // Failed to register LLMs.txt endpoint
1766 }
1767
1768 try {
1769 $global_seo_endpoint = new Global_SEO_Endpoint();
1770 $global_seo_endpoint->register_routes();
1771 } catch (\Exception $e) {
1772 // Failed to register Global SEO endpoint
1773 }
1774
1775 try {
1776 $image_seo_endpoint = new Image_SEO_Endpoint();
1777 $image_seo_endpoint->register_routes();
1778 } catch (\Exception $e) {
1779 // Failed to register Image SEO endpoint
1780 }
1781
1782 try {
1783 $import_controller = new Import_Controller();
1784 $import_controller->register_routes();
1785 } catch (\Exception $e) {
1786 // Failed to register Import endpoint
1787 }
1788
1789 try {
1790 $setup_wizard_endpoint = new Setup_Wizard_Endpoint();
1791 $setup_wizard_endpoint->register_routes();
1792 } catch (\Exception $e) {
1793 // Failed to register Setup Wizard endpoint
1794 }
1795 }
1796 }
1797