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

1,873 lines 72.0 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', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL),
764 'claude_api_key' => $settings_instance->get('claude_api_key', ''),
765 'claude_model' => $settings_instance->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL),
766 'gemini_api_key' => $settings_instance->get('gemini_api_key', ''),
767 'gemini_model' => $settings_instance->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL),
768 'openrouter_api_key' => $settings_instance->get('openrouter_api_key', ''),
769 'openrouter_model' => $settings_instance->get('openrouter_model', \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL),
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'], true)) {
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 // A failed write is skipped rather than aborting the batch, so
871 // one bad setting cannot block the rest of the save.
872 $settings->set($setting_key, $value);
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 // Read the pending flag BEFORE the meta below, never after. A writer
960 // that finishes mid-request writes the meta and *then* clears the
961 // flag; reading the flag last could therefore observe "no value" and
962 // "not pending" for the same run and stop the editor panel polling one
963 // tick before the value it was waiting for lands (#329).
964 $pending = \ThinkRank\SEO\Metadata_Pending::is_pending($post_id);
965
966 // Get existing metadata. These must read the same canonical meta keys
967 // the rest of the plugin writes/reads (frontend, metabox, scoring),
968 // otherwise the response is always empty:
969 // title/description → _thinkrank_seo_title / _thinkrank_meta_description
970 // keywords → Focus_Keywords (stored as _thinkrank_focus_keywords)
971 // last_generated → _thinkrank_generated_at (written by Metadata_Generator)
972 $metadata = [
973 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true),
974 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true),
975 'keywords' => \ThinkRank\SEO\Focus_Keywords::get($post_id),
976 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true) ?: 0,
977 'last_generated' => get_post_meta($post_id, '_thinkrank_generated_at', true),
978 // Whether a background writer (Auto AI on publish, bulk
979 // optimization, imports) is about to fill these fields. The editor
980 // panel polls only while this is true.
981 'pending' => $pending,
982 ];
983
984 return new \WP_REST_Response($metadata);
985 }
986
987 /**
988 * Generate AI-powered SEO metadata
989 *
990 * @param \WP_REST_Request $request Request object containing content and generation options
991 * @return \WP_REST_Response Response object with generated metadata or error message
992 * @throws \Exception When AI metadata generation fails or AI client is unavailable
993 */
994 public function generate_ai_metadata(\WP_REST_Request $request): \WP_REST_Response {
995 $content = $request->get_param('content');
996 $options = [
997 'target_keyword' => $request->get_param('target_keyword'),
998 'content_type' => $request->get_param('content_type'),
999 'tone' => $request->get_param('tone'),
1000 // Instruct the model to write in the post/site language instead of
1001 // defaulting to English on non-English sites (issue #234).
1002 'language' => \ThinkRank\AI\Language_Resolver::resolve((int) $request->get_param('post_id')),
1003 ];
1004
1005 // Rate limiting: per user/IP per route
1006 $user_id = get_current_user_id();
1007 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1008 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1009 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1010 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1011 if (is_wp_error($allowed)) {
1012 return new \WP_REST_Response([
1013 'success' => false,
1014 'message' => $allowed->get_error_message(),
1015 ], $allowed->get_error_data()['status'] ?? 429);
1016 }
1017
1018 try {
1019 // Get AI manager instance
1020 $ai_manager = new \ThinkRank\AI\Manager();
1021 $ai_manager->initialize_client();
1022
1023 // Run through the generator so title/description are capped to the
1024 // configured limits and the character counts are returned.
1025 $generator = new \ThinkRank\AI\Metadata_Generator($ai_manager);
1026 $metadata = $generator->generate_for_content($content, $options);
1027
1028 return new \WP_REST_Response([
1029 'success' => true,
1030 'data' => $metadata,
1031 'message' => __('SEO metadata generated successfully', 'thinkrank'),
1032 ]);
1033 } catch (\Exception $e) {
1034 return new \WP_REST_Response([
1035 'success' => false,
1036 'message' => $e->getMessage(),
1037 ], 400);
1038 }
1039 }
1040
1041 /**
1042 * Generate and return an improved SEO title for an "Apply" suggestion action.
1043 *
1044 * @param \WP_REST_Request $request Request object.
1045 * @return \WP_REST_Response Response with the improved title under data.title.
1046 * @throws \Exception When title improvement fails or the AI client is unavailable.
1047 */
1048 public function improve_ai_title(\WP_REST_Request $request): \WP_REST_Response {
1049 $content = $request->get_param('content');
1050 $options = [
1051 'current_title' => $request->get_param('current_title'),
1052 'target_keyword' => $request->get_param('target_keyword'),
1053 'content_type' => $request->get_param('content_type'),
1054 'tone' => $request->get_param('tone'),
1055 'suggestion' => $request->get_param('suggestion'),
1056 'language' => \ThinkRank\AI\Language_Resolver::resolve((int) $request->get_param('post_id')),
1057 ];
1058
1059 // Rate limiting: shares the AI generation bucket.
1060 $user_id = get_current_user_id();
1061 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1062 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1063 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1064 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1065 if (is_wp_error($allowed)) {
1066 return new \WP_REST_Response([
1067 'success' => false,
1068 'message' => $allowed->get_error_message(),
1069 ], $allowed->get_error_data()['status'] ?? 429);
1070 }
1071
1072 try {
1073 $ai_manager = new \ThinkRank\AI\Manager();
1074 $ai_manager->initialize_client();
1075
1076 $result = $ai_manager->improve_seo_title($content, $options);
1077
1078 return new \WP_REST_Response([
1079 'success' => true,
1080 'data' => $result,
1081 'message' => __('SEO title improved successfully', 'thinkrank'),
1082 ]);
1083 } catch (\Exception $e) {
1084 return new \WP_REST_Response([
1085 'success' => false,
1086 'message' => $e->getMessage(),
1087 ], 400);
1088 }
1089 }
1090
1091 /**
1092 * Generate and return an improved meta description for an "Apply" action.
1093 *
1094 * @param \WP_REST_Request $request Request object.
1095 * @return \WP_REST_Response Response with the description under data.description.
1096 * @throws \Exception When generation fails or the AI client is unavailable.
1097 */
1098 public function improve_ai_meta_description(\WP_REST_Request $request): \WP_REST_Response {
1099 $content = $request->get_param('content');
1100 $options = [
1101 'current_description' => $request->get_param('current_description'),
1102 'target_keyword' => $request->get_param('target_keyword'),
1103 'content_type' => $request->get_param('content_type'),
1104 'tone' => $request->get_param('tone'),
1105 'suggestion' => $request->get_param('suggestion'),
1106 'language' => \ThinkRank\AI\Language_Resolver::resolve((int) $request->get_param('post_id')),
1107 ];
1108
1109 // Rate limiting: shares the AI generation bucket.
1110 $user_id = get_current_user_id();
1111 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1112 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1113 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1114 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1115 if (is_wp_error($allowed)) {
1116 return new \WP_REST_Response([
1117 'success' => false,
1118 'message' => $allowed->get_error_message(),
1119 ], $allowed->get_error_data()['status'] ?? 429);
1120 }
1121
1122 try {
1123 $ai_manager = new \ThinkRank\AI\Manager();
1124 $ai_manager->initialize_client();
1125
1126 $result = $ai_manager->improve_meta_description($content, $options);
1127
1128 return new \WP_REST_Response([
1129 'success' => true,
1130 'data' => $result,
1131 'message' => __('Meta description generated successfully', 'thinkrank'),
1132 ]);
1133 } catch (\Exception $e) {
1134 return new \WP_REST_Response([
1135 'success' => false,
1136 'message' => $e->getMessage(),
1137 ], 400);
1138 }
1139 }
1140
1141 /**
1142 * Explain a single SEO suggestion in plain, post-specific language.
1143 *
1144 * Read-only copilot action: returns a short AI explanation of why the
1145 * suggestion matters for this post and how to resolve it. Does not modify
1146 * any content.
1147 *
1148 * @param \WP_REST_Request $request Request object.
1149 * @return \WP_REST_Response Response with the explanation under data.explanation.
1150 * @throws \Exception When generation fails or the AI client is unavailable.
1151 */
1152 public function explain_ai_suggestion(\WP_REST_Request $request): \WP_REST_Response {
1153 $content = $request->get_param('content');
1154 $options = [
1155 'suggestion' => $request->get_param('suggestion'),
1156 'title' => $request->get_param('title'),
1157 'target_keyword' => $request->get_param('target_keyword'),
1158 'content_type' => $request->get_param('content_type'),
1159 ];
1160
1161 // Rate limiting: shares the AI generation bucket.
1162 $user_id = get_current_user_id();
1163 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1164 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1165 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1166 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1167 if (is_wp_error($allowed)) {
1168 return new \WP_REST_Response([
1169 'success' => false,
1170 'message' => $allowed->get_error_message(),
1171 ], $allowed->get_error_data()['status'] ?? 429);
1172 }
1173
1174 try {
1175 $ai_manager = new \ThinkRank\AI\Manager();
1176 $ai_manager->initialize_client();
1177
1178 $result = $ai_manager->explain_seo_suggestion($content, $options);
1179
1180 return new \WP_REST_Response([
1181 'success' => true,
1182 'data' => $result,
1183 'message' => __('Explanation generated successfully', 'thinkrank'),
1184 ]);
1185 } catch (\Exception $e) {
1186 return new \WP_REST_Response([
1187 'success' => false,
1188 'message' => $e->getMessage(),
1189 ], 400);
1190 }
1191 }
1192
1193 /**
1194 * Generate a content fragment with one authoritative external dofollow link.
1195 *
1196 * @param \WP_REST_Request $request Request object.
1197 * @return \WP_REST_Response Response with the HTML fragment under data.html.
1198 * @throws \Exception When generation fails or the AI client is unavailable.
1199 */
1200 public function add_ai_dofollow_link(\WP_REST_Request $request): \WP_REST_Response {
1201 $content = $request->get_param('content');
1202 $options = [
1203 'target_keyword' => $request->get_param('target_keyword'),
1204 'content_type' => $request->get_param('content_type'),
1205 ];
1206
1207 // Rate limiting: shares the AI generation bucket.
1208 $user_id = get_current_user_id();
1209 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1210 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1211 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1212 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1213 if (is_wp_error($allowed)) {
1214 return new \WP_REST_Response([
1215 'success' => false,
1216 'message' => $allowed->get_error_message(),
1217 ], $allowed->get_error_data()['status'] ?? 429);
1218 }
1219
1220 try {
1221 $ai_manager = new \ThinkRank\AI\Manager();
1222 $ai_manager->initialize_client();
1223
1224 $result = $ai_manager->generate_dofollow_link($content, $options);
1225
1226 return new \WP_REST_Response([
1227 'success' => true,
1228 'data' => $result,
1229 'message' => __('Added an authoritative source link', 'thinkrank'),
1230 ]);
1231 } catch (\Exception $e) {
1232 return new \WP_REST_Response([
1233 'success' => false,
1234 'message' => $e->getMessage(),
1235 ], 400);
1236 }
1237 }
1238
1239 /**
1240 * Generate a keyword-rich paragraph to lift keyword density into band.
1241 *
1242 * @param \WP_REST_Request $request Request object.
1243 * @return \WP_REST_Response Response with the HTML fragment under data.html.
1244 * @throws \Exception When generation fails or the AI client is unavailable.
1245 */
1246 public function add_ai_keyword_paragraph(\WP_REST_Request $request): \WP_REST_Response {
1247 $content = $request->get_param('content');
1248 $options = [
1249 'target_keyword' => $request->get_param('target_keyword'),
1250 'content_type' => $request->get_param('content_type'),
1251 'tone' => $request->get_param('tone'),
1252 'word_count' => $request->get_param('word_count'),
1253 'keyword_count' => $request->get_param('keyword_count'),
1254 ];
1255
1256 // Rate limiting: shares the AI generation bucket.
1257 $user_id = get_current_user_id();
1258 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1259 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1260 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1261 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1262 if (is_wp_error($allowed)) {
1263 return new \WP_REST_Response([
1264 'success' => false,
1265 'message' => $allowed->get_error_message(),
1266 ], $allowed->get_error_data()['status'] ?? 429);
1267 }
1268
1269 try {
1270 $ai_manager = new \ThinkRank\AI\Manager();
1271 $ai_manager->initialize_client();
1272
1273 $result = $ai_manager->generate_keyword_paragraph($content, $options);
1274
1275 return new \WP_REST_Response([
1276 'success' => true,
1277 'data' => $result,
1278 'message' => __('Added a keyword-focused paragraph', 'thinkrank'),
1279 ]);
1280 } catch (\Exception $e) {
1281 return new \WP_REST_Response([
1282 'success' => false,
1283 'message' => $e->getMessage(),
1284 ], 400);
1285 }
1286 }
1287
1288 /**
1289 * Enable ThinkRank's Global SEO schema output for a post's post type.
1290 *
1291 * Sets a sensible default schema type (Article for posts, WebPage for pages)
1292 * when none is configured yet, so ThinkRank emits JSON-LD for the post. This
1293 * resolves the "add structured data" suggestion, which the scorer now credits
1294 * when ThinkRank schema is active.
1295 *
1296 * @param \WP_REST_Request $request Request object.
1297 * @return \WP_REST_Response Response describing the enabled schema type.
1298 */
1299 public function enable_schema_for_post(\WP_REST_Request $request): \WP_REST_Response {
1300 $post_id = (int) $request->get_param('post_id');
1301 $post = get_post($post_id);
1302 if (!$post) {
1303 return new \WP_REST_Response([
1304 'success' => false,
1305 'message' => __('Post not found.', 'thinkrank'),
1306 ], 404);
1307 }
1308
1309 $post_type = $post->post_type;
1310 $settings = get_option('thinkrank_global_seo_settings', []);
1311 if (!is_array($settings)) {
1312 $settings = [];
1313 }
1314 if (!isset($settings[$post_type]) || !is_array($settings[$post_type])) {
1315 $settings[$post_type] = [];
1316 }
1317
1318 $already_enabled = !empty($settings[$post_type]['schema_type']);
1319 if (!$already_enabled) {
1320 if ($post_type === 'page') {
1321 $settings[$post_type]['schema_type'] = 'WebPage';
1322 } else {
1323 $settings[$post_type]['schema_type'] = 'Article';
1324 if (empty($settings[$post_type]['article_type'])) {
1325 $settings[$post_type]['article_type'] = 'BlogPosting';
1326 }
1327 }
1328 update_option('thinkrank_global_seo_settings', $settings);
1329 }
1330
1331 $schema_type = $settings[$post_type]['schema_type'];
1332
1333 return new \WP_REST_Response([
1334 'success' => true,
1335 'data' => [
1336 'schema_type' => $schema_type,
1337 'post_type' => $post_type,
1338 'already_enabled' => $already_enabled,
1339 ],
1340 'message' => $already_enabled
1341 ? __('Schema was already enabled for this post type.', 'thinkrank')
1342 /* translators: %s: schema type. */
1343 : sprintf(__('Enabled %s schema for this post type.', 'thinkrank'), $schema_type),
1344 ]);
1345 }
1346
1347 /**
1348 * Test AI connection for specified provider
1349 *
1350 * @param \WP_REST_Request $request Request object containing api_key and provider parameters
1351 * @return \WP_REST_Response Response object with connection test results
1352 * @throws \Exception When API connection test encounters unexpected errors
1353 */
1354 public function test_ai_connection(\WP_REST_Request $request): \WP_REST_Response {
1355 // Rate limiting: per user/IP per route
1356 $user_id = get_current_user_id();
1357 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1358 $bucket_id = 'ai_test|' . ($user_id ?: $ip);
1359 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1360 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1361 if (is_wp_error($allowed)) {
1362 return new \WP_REST_Response([
1363 'success' => false,
1364 'message' => $allowed->get_error_message(),
1365 ], $allowed->get_error_data()['status'] ?? 429);
1366 }
1367
1368 try {
1369 $api_key = $request->get_param('api_key');
1370 $provider = $request->get_param('provider') ?: 'openai';
1371
1372 // If no API key provided in request, try to get from saved settings
1373 if (empty($api_key)) {
1374 $settings = \ThinkRank\Core\Settings::instance();
1375 if ($provider === 'openai') {
1376 $api_key = (string) $settings->get('openai_api_key', '');
1377 } elseif ($provider === 'claude') {
1378 $api_key = (string) $settings->get('claude_api_key', '');
1379 } elseif ($provider === 'openrouter') {
1380 $api_key = (string) $settings->get('openrouter_api_key', '');
1381 } else {
1382 $api_key = (string) $settings->get('gemini_api_key', '');
1383 }
1384
1385 if (empty($api_key)) {
1386 return new \WP_REST_Response([
1387 'success' => false,
1388 'message' => __('No API key provided or saved for the selected provider.', 'thinkrank'),
1389 ], 400);
1390 }
1391 }
1392
1393 // Test the connection with a simple API call
1394 if ($provider === 'openai') {
1395 $result = $this->test_openai_connection($api_key);
1396 } elseif ($provider === 'claude') {
1397 $result = $this->test_claude_connection($api_key);
1398 } elseif ($provider === 'openrouter') {
1399 $result = $this->test_openrouter_connection($api_key);
1400 } else {
1401 $result = $this->test_gemini_connection($api_key);
1402 }
1403
1404 return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
1405 } catch (\Exception $e) {
1406 return new \WP_REST_Response([
1407 'success' => false,
1408 'message' => $e->getMessage(),
1409 ], 500);
1410 }
1411 }
1412
1413 /**
1414 * Test OpenAI API connection
1415 *
1416 * @param string $api_key API key to test
1417 * @return array Test result
1418 */
1419 private function test_openai_connection(string $api_key): array {
1420 $url = 'https://api.openai.com/v1/models';
1421
1422 $response = wp_remote_get($url, [
1423 'headers' => [
1424 'Authorization' => 'Bearer ' . $api_key,
1425 'Content-Type' => 'application/json',
1426 ],
1427 'timeout' => 10,
1428 ]);
1429
1430 if (is_wp_error($response)) {
1431 return [
1432 'success' => false,
1433 'message' => __('Failed to connect to OpenAI API: ', 'thinkrank') . $response->get_error_message(),
1434 ];
1435 }
1436
1437 $status_code = wp_remote_retrieve_response_code($response);
1438 $body = wp_remote_retrieve_body($response);
1439
1440 if ($status_code === 200) {
1441 $data = json_decode($body, true);
1442 if (isset($data['data']) && is_array($data['data'])) {
1443 return [
1444 'success' => true,
1445 'message' => __('OpenAI API connection successful!', 'thinkrank'),
1446 'models_count' => count($data['data']),
1447 ];
1448 }
1449 }
1450
1451 // Handle error response
1452 $error_data = json_decode($body, true);
1453 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1454
1455 return [
1456 'success' => false,
1457 'message' => __('OpenAI API Error: ', 'thinkrank') . $error_message,
1458 ];
1459 }
1460
1461 /**
1462 * Test OpenRouter API connection
1463 *
1464 * @param string $api_key API key to test
1465 * @return array Test result
1466 */
1467 private function test_openrouter_connection(string $api_key): array {
1468 // Validate the key format first (OpenRouter keys start with "sk-or-").
1469 if (!str_starts_with($api_key, 'sk-or-')) {
1470 return [
1471 'success' => false,
1472 'message' => __('Invalid OpenRouter API key format. Should start with "sk-or-"', 'thinkrank'),
1473 ];
1474 }
1475
1476 // The key endpoint validates the credential and returns its metadata.
1477 $url = 'https://openrouter.ai/api/v1/key';
1478
1479 $response = wp_remote_get($url, [
1480 'headers' => [
1481 'Authorization' => 'Bearer ' . $api_key,
1482 'Content-Type' => 'application/json',
1483 'HTTP-Referer' => home_url('/'),
1484 'X-Title' => 'ThinkRank',
1485 ],
1486 'timeout' => 10,
1487 ]);
1488
1489 if (is_wp_error($response)) {
1490 return [
1491 'success' => false,
1492 'message' => __('Failed to connect to OpenRouter API: ', 'thinkrank') . $response->get_error_message(),
1493 ];
1494 }
1495
1496 $status_code = wp_remote_retrieve_response_code($response);
1497 $body = wp_remote_retrieve_body($response);
1498
1499 if ($status_code === 200) {
1500 $data = json_decode($body, true);
1501 if (isset($data['data']) && is_array($data['data'])) {
1502 return [
1503 'success' => true,
1504 'message' => __('OpenRouter API connection successful!', 'thinkrank'),
1505 ];
1506 }
1507 }
1508
1509 // Handle error response
1510 $error_data = json_decode($body, true);
1511 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1512
1513 return [
1514 'success' => false,
1515 'message' => __('OpenRouter API Error: ', 'thinkrank') . $error_message,
1516 ];
1517 }
1518
1519 /**
1520 * Test Claude API connection
1521 *
1522 * @param string $api_key API key to test
1523 * @return array Test result
1524 */
1525 private function test_claude_connection(string $api_key): array {
1526 // First validate the key format
1527 if (!str_starts_with($api_key, 'sk-ant-')) {
1528 return [
1529 'success' => false,
1530 'message' => __('Invalid Claude API key format. Should start with "sk-ant-"', 'thinkrank'),
1531 ];
1532 }
1533
1534 // Test with a simple API call
1535 $url = 'https://api.anthropic.com/v1/messages';
1536
1537 // Get the configured Claude model, with fallback to a current model.
1538 // Self-heal retired/unavailable IDs saved by earlier versions.
1539 $claude_model = \ThinkRank\Core\Settings::instance()->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL);
1540 $claude_model = \ThinkRank\AI\Claude_Client::normalize_model($claude_model);
1541
1542 $body = [
1543 'model' => $claude_model,
1544 'max_tokens' => 10,
1545 'messages' => [
1546 [
1547 'role' => 'user',
1548 'content' => 'Hello'
1549 ]
1550 ]
1551 ];
1552
1553 $response = wp_remote_post($url, [
1554 'headers' => [
1555 'x-api-key' => $api_key,
1556 'Content-Type' => 'application/json',
1557 'anthropic-version' => '2023-06-01',
1558 ],
1559 'body' => wp_json_encode($body),
1560 'timeout' => 10,
1561 ]);
1562
1563 if (is_wp_error($response)) {
1564 return [
1565 'success' => false,
1566 'message' => __('Failed to connect to Claude API: ', 'thinkrank') . $response->get_error_message(),
1567 ];
1568 }
1569
1570 $status_code = wp_remote_retrieve_response_code($response);
1571 $response_body = wp_remote_retrieve_body($response);
1572
1573 if ($status_code === 200) {
1574 return [
1575 'success' => true,
1576 'message' => __('Claude API connection successful!', 'thinkrank'),
1577 ];
1578 } else {
1579 $error_data = json_decode($response_body, true);
1580 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1581
1582 return [
1583 'success' => false,
1584 /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */
1585 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1586 ];
1587 }
1588 }
1589
1590 /**
1591 * Test Gemini API connection
1592 *
1593 * @param string $api_key API key to test
1594 * @return array Test result
1595 */
1596 private function test_gemini_connection(string $api_key): array {
1597 // Test with a simple API call
1598 $gemini_model = \ThinkRank\Core\Settings::instance()->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL);
1599 $url = "https://generativelanguage.googleapis.com/v1beta/models/{$gemini_model}:generateContent?key={$api_key}";
1600
1601 $body = [
1602 'contents' => [
1603 [
1604 'parts' => [
1605 ['text' => 'Hello']
1606 ]
1607 ]
1608 ],
1609 'generationConfig' => [
1610 'maxOutputTokens' => 10,
1611 'temperature' => 0.1,
1612 ]
1613 ];
1614
1615 $response = wp_remote_post($url, [
1616 'headers' => [
1617 'Content-Type' => 'application/json',
1618 ],
1619 'body' => wp_json_encode($body),
1620 'timeout' => 10,
1621 ]);
1622
1623 if (is_wp_error($response)) {
1624 return [
1625 'success' => false,
1626 'message' => __('Failed to connect to Gemini API: ', 'thinkrank') . $response->get_error_message(),
1627 ];
1628 }
1629
1630 $status_code = wp_remote_retrieve_response_code($response);
1631 $response_body = wp_remote_retrieve_body($response);
1632
1633 if ($status_code === 200) {
1634 return [
1635 'success' => true,
1636 'message' => __('Gemini API connection successful!', 'thinkrank'),
1637 ];
1638 } else {
1639 $error_data = json_decode($response_body, true);
1640 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1641
1642 return [
1643 'success' => false,
1644 /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */
1645 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1646 ];
1647 }
1648 }
1649
1650 /**
1651 * Get AI providers
1652 *
1653 * @param \WP_REST_Request $request Request object
1654 * @return \WP_REST_Response Response object
1655 */
1656 public function get_ai_providers(\WP_REST_Request $request): \WP_REST_Response {
1657 $ai_manager = new \ThinkRank\AI\Manager();
1658 $providers = $ai_manager->get_available_providers();
1659
1660 return new \WP_REST_Response($providers);
1661 }
1662
1663 /**
1664 * Get AI status
1665 *
1666 * @param \WP_REST_Request $request Request object
1667 * @return \WP_REST_Response Response object
1668 */
1669 public function get_ai_status(\WP_REST_Request $request): \WP_REST_Response {
1670 $ai_manager = new \ThinkRank\AI\Manager();
1671 $status = $ai_manager->get_provider_status();
1672
1673 return new \WP_REST_Response($status);
1674 }
1675
1676 /**
1677 * Analyze content for SEO optimization
1678 *
1679 * @param \WP_REST_Request $request Request object containing content, metadata, and optional post_id
1680 * @return \WP_REST_Response Response object with analysis results or error message
1681 * @throws \Exception When AI analysis fails or AI client initialization fails
1682 */
1683 public function analyze_content(\WP_REST_Request $request): \WP_REST_Response {
1684 $content = $request->get_param('content');
1685 $metadata = $request->get_param('metadata') ?: [];
1686 $post_id = $request->get_param('post_id');
1687
1688 // Rate limiting: per user/IP per route
1689 $user_id = get_current_user_id();
1690 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1691 $bucket_id = 'ai_analyze|' . ($user_id ?: $ip);
1692 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1693 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1694 if (is_wp_error($allowed)) {
1695 return new \WP_REST_Response([
1696 'success' => false,
1697 'message' => $allowed->get_error_message(),
1698 ], $allowed->get_error_data()['status'] ?? 429);
1699 }
1700
1701 try {
1702 // Get AI manager instance
1703 $ai_manager = new \ThinkRank\AI\Manager();
1704 $ai_manager->initialize_client();
1705
1706 // Perform content analysis
1707 $analysis = $ai_manager->analyze_content($content, $metadata);
1708
1709 return new \WP_REST_Response([
1710 'success' => true,
1711 'data' => $analysis,
1712 'message' => __('Content analyzed successfully', 'thinkrank'),
1713 ]);
1714 } catch (\Exception $e) {
1715 return new \WP_REST_Response([
1716 'success' => false,
1717 'message' => $e->getMessage(),
1718 ], 400);
1719 }
1720 }
1721
1722 /**
1723 * Sanitize metadata object for API endpoints
1724 *
1725 * @param mixed $metadata Metadata to sanitize
1726 * @return array Sanitized metadata array
1727 */
1728 public function sanitize_metadata_object($metadata): array {
1729 if (!is_array($metadata)) {
1730 return [];
1731 }
1732
1733 $sanitized = [];
1734 foreach ($metadata as $key => $value) {
1735 $sanitized_key = sanitize_key($key);
1736
1737 if (is_string($value)) {
1738 $sanitized[$sanitized_key] = sanitize_text_field($value);
1739 } elseif (is_array($value)) {
1740 // Recursively sanitize nested arrays
1741 $sanitized[$sanitized_key] = array_map('sanitize_text_field', $value);
1742 } elseif (is_numeric($value)) {
1743 $sanitized[$sanitized_key] = (float) $value;
1744 } elseif (is_bool($value)) {
1745 $sanitized[$sanitized_key] = (bool) $value;
1746 }
1747 // Skip other data types for security
1748 }
1749
1750 return $sanitized;
1751 }
1752
1753 /**
1754 * Register endpoint classes
1755 *
1756 * @return void
1757 */
1758 public function register_endpoint_classes(): void {
1759 // Register endpoint classes that exist
1760 try {
1761 $site_identity_endpoint = new Site_Identity_Endpoint();
1762 $site_identity_endpoint->register_routes();
1763 } catch (\Exception $e) {
1764 // Failed to register Site Identity endpoint
1765 }
1766
1767 try {
1768 $ai_insights_endpoint = new Ai_Insights_Endpoint();
1769 $ai_insights_endpoint->register_routes();
1770 } catch (\Exception $e) {
1771 // Failed to register AI Insights endpoint
1772 }
1773
1774 try {
1775 $brand_visibility_endpoint = new Brand_Visibility_Endpoint();
1776 $brand_visibility_endpoint->register_routes();
1777 } catch (\Exception $e) {
1778 // Failed to register Brand Visibility endpoint
1779 }
1780
1781 try {
1782 $performance_endpoint = new Performance_Endpoint();
1783 $performance_endpoint->register_routes();
1784 } catch (\Exception $e) {
1785 // Failed to register Performance endpoint
1786 }
1787
1788 try {
1789 $schema_endpoint = new Schema_Endpoint();
1790 $schema_endpoint->register_routes();
1791 } catch (\Exception $e) {
1792 // Failed to register Schema endpoint
1793 }
1794
1795 try {
1796 $settings_endpoint = new Settings_Management_Endpoint();
1797 $settings_endpoint->register_routes();
1798 } catch (\Exception $e) {
1799 // Failed to register Settings Management endpoint
1800 }
1801
1802 try {
1803 $integrations_endpoint = new Integrations_Endpoint();
1804 $integrations_endpoint->register_routes();
1805 } catch (\Exception $e) {
1806 // Failed to register Integrations endpoint
1807 }
1808
1809 try {
1810 $social_platforms_endpoint = new Social_Platforms_Endpoint();
1811 $social_platforms_endpoint->register_routes();
1812 } catch (\Exception $e) {
1813 // Failed to register Social Platforms endpoint
1814 }
1815
1816 try {
1817 $content_brief_endpoint = new Content_Brief_Endpoint();
1818 $content_brief_endpoint->register_routes();
1819 } catch (\Exception $e) {
1820 // Failed to register Content Brief endpoint
1821 }
1822
1823 try {
1824 $social_media_endpoint = new Social_Media_Endpoint();
1825 $social_media_endpoint->register_routes();
1826 } catch (\Exception $e) {
1827 // Failed to register Social Media endpoint
1828 }
1829
1830 try {
1831 $sitemap_endpoint = new Sitemap_Endpoint();
1832 $sitemap_endpoint->register_routes();
1833 } catch (\Exception $e) {
1834 // Failed to register Sitemap endpoint
1835 }
1836
1837 try {
1838 $llms_txt_endpoint = new LLMs_Txt_Endpoint();
1839 $llms_txt_endpoint->register_routes();
1840 } catch (\Exception $e) {
1841 // Failed to register LLMs.txt endpoint
1842 }
1843
1844 try {
1845 $global_seo_endpoint = new Global_SEO_Endpoint();
1846 $global_seo_endpoint->register_routes();
1847 } catch (\Exception $e) {
1848 // Failed to register Global SEO endpoint
1849 }
1850
1851 try {
1852 $image_seo_endpoint = new Image_SEO_Endpoint();
1853 $image_seo_endpoint->register_routes();
1854 } catch (\Exception $e) {
1855 // Failed to register Image SEO endpoint
1856 }
1857
1858 try {
1859 $import_controller = new Import_Controller();
1860 $import_controller->register_routes();
1861 } catch (\Exception $e) {
1862 // Failed to register Import endpoint
1863 }
1864
1865 try {
1866 $setup_wizard_endpoint = new Setup_Wizard_Endpoint();
1867 $setup_wizard_endpoint->register_routes();
1868 } catch (\Exception $e) {
1869 // Failed to register Setup Wizard endpoint
1870 }
1871 }
1872 }
1873