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

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

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