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

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