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

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