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

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