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

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

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