PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.1
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / api / class-manager.php

class-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.1.1, at includes/api/class-manager.php

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