PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.0
2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.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 All 55 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.14.0, at includes/api/class-manager.php

2,806 lines 115.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\External_Links_Endpoint;
32 use ThinkRank\API\Instant_Indexing_Endpoint;
33 use ThinkRank\API\Pillar_Content_Endpoint;
34 use ThinkRank\API\Global_Robot_Meta_Endpoint;
35 use ThinkRank\API\Author_Archives_Endpoint;
36 use ThinkRank\API\Email_Report_Endpoint;
37 use ThinkRank\Admin\Importers\Import_Controller;
38 use ThinkRank\Admin\Importers\Export_Controller;
39 use ThinkRank\API\Setup_Wizard_Endpoint;
40
41
42 // Prevent direct access
43 if (!defined('ABSPATH')) {
44 exit;
45 }
46
47 /**
48 * API Manager Class
49 *
50 * Single Responsibility: Manage REST API endpoints
51 *
52 * @since 1.0.0
53 */
54 class Manager {
55
56 /**
57 * API namespace
58 *
59 * @var string
60 */
61 private const NAMESPACE = 'thinkrank/v1';
62
63 /**
64 * Maximum accepted length (characters) for AI `content` payloads. Enforced
65 * at the REST boundary so oversized input can't drive expensive prompt
66 * building, cache hashing, and AI requests/retries. Mirrors the frontend's
67 * 5000-character trim.
68 */
69 private const AI_CONTENT_MAX_LENGTH = 5000;
70
71 /**
72 * Ceilings for the OpenAI-compatible endpoint's model listing (#721).
73 *
74 * The endpoint is a host the site owner named, not one we trust: a
75 * misconfigured or hostile server can answer `GET /models` with an
76 * unbounded body or a catalogue of thousands. Both are bounded here — the
77 * field this feeds is a suggestion list, and the UI shows the first few.
78 */
79 private const MAX_MODELS_RESPONSE_BYTES = 262144; // 256 KB.
80 private const MAX_ENDPOINT_MODELS = 200;
81
82 /**
83 * Sanitize and hard-cap an AI `content` request parameter.
84 *
85 * Used as the `sanitize_callback` for every AI endpoint's `content` arg so
86 * the server enforces its own maximum regardless of what a direct REST
87 * caller sends.
88 *
89 * @param mixed $value Raw request value.
90 * @return string Sanitized content, truncated to AI_CONTENT_MAX_LENGTH.
91 */
92 public function sanitize_ai_content($value): string {
93 return mb_substr(sanitize_textarea_field((string) $value), 0, self::AI_CONTENT_MAX_LENGTH);
94 }
95
96 /**
97 * Initialize API manager
98 *
99 * @return void
100 */
101 public function init(): void {
102 add_action('rest_api_init', [$this, 'register_routes']);
103 add_action('rest_api_init', [$this, 'register_endpoint_classes']);
104
105 // Analytics cache invalidation must listen on every request, not only
106 // REST ones — AI usage is logged from cron and WP-CLI too, and a
107 // listener bound on rest_api_init never hears those.
108 Usage_Analytics_Endpoint::boot_cache_invalidation();
109
110 // Make declared schema constraints mean something. Applied once over
111 // the whole namespace rather than at 70-odd call sites, because that is
112 // exactly how the enum on /setup-wizard/migrated-plugins and the one on
113 // /seo-analytics/dashboard came to be inert while the route next door
114 // was fine (#394). Late priority so it sees every route, including any
115 // an add-on registered.
116 add_filter('rest_endpoints', [Rest_Args::class, 'enforce_namespace'], 99);
117 }
118
119 /**
120 * Register REST API routes
121 *
122 * @return void
123 */
124 public function register_routes(): void {
125 // Core endpoints
126 register_rest_route(self::NAMESPACE, '/capabilities', [
127 'methods' => 'GET',
128 'callback' => [$this, 'get_capabilities'],
129 'permission_callback' => [$this, 'check_basic_permissions'],
130 ]);
131
132 register_rest_route(self::NAMESPACE, '/plugin-info', [
133 'methods' => 'GET',
134 'callback' => [$this, 'get_plugin_info'],
135 'permission_callback' => [$this, 'check_basic_permissions'],
136 ]);
137
138 register_rest_route(self::NAMESPACE, '/system-status', [
139 'methods' => 'GET',
140 'callback' => [$this, 'get_system_status'],
141 'permission_callback' => [$this, 'check_basic_permissions'],
142 ]);
143
144 // Integration health check for MCP/Abilities clients (see #188). This
145 // route is intentionally gated only by the admin capability, NOT by the
146 // `enable_mcp` toggle, so it stays reachable as a diagnostic even when
147 // the MCP server is off or abilities failed to register.
148 register_rest_route(self::NAMESPACE, '/connection-status', [
149 'methods' => 'GET',
150 'callback' => [$this, 'get_connection_status'],
151 'permission_callback' => [$this, 'check_admin_permissions'],
152 ]);
153
154 // Settings endpoints
155 register_rest_route(self::NAMESPACE, '/settings', [
156 'methods' => 'GET',
157 'callback' => [$this, 'get_settings'],
158 'permission_callback' => [$this, 'check_settings_permissions'],
159 ]);
160
161 register_rest_route(self::NAMESPACE, '/settings', [
162 'methods' => 'POST',
163 'callback' => [$this, 'save_settings'],
164 'permission_callback' => [$this, 'check_settings_permissions'],
165 'args' => [
166 'ai_provider' => [
167 'type' => 'string',
168 // Includes '' (Settings::AI_PROVIDER_NONE) so a client can
169 // clear the selection, not just switch between providers.
170 'enum' => \ThinkRank\Core\Settings::selectable_ai_providers(),
171 'sanitize_callback' => 'sanitize_key',
172 // The enum is inert without this: has_valid_params() skips
173 // an arg entirely unless a validate_callback is set (#394).
174 'validate_callback' => 'rest_validate_request_arg',
175 ],
176 'openai_api_key' => [
177 'type' => 'string',
178 'sanitize_callback' => 'sanitize_text_field',
179 ],
180 'openai_model' => [
181 'type' => 'string',
182 'sanitize_callback' => 'sanitize_text_field',
183 ],
184 'claude_api_key' => [
185 'type' => 'string',
186 'sanitize_callback' => 'sanitize_text_field',
187 ],
188 'claude_model' => [
189 'type' => 'string',
190 'sanitize_callback' => 'sanitize_text_field',
191 ],
192 'gemini_api_key' => [
193 'type' => 'string',
194 'sanitize_callback' => 'sanitize_text_field',
195 ],
196 'gemini_model' => [
197 'type' => 'string',
198 'sanitize_callback' => 'sanitize_text_field',
199 ],
200 'openrouter_api_key' => [
201 'type' => 'string',
202 'sanitize_callback' => 'sanitize_text_field',
203 ],
204 'openrouter_model' => [
205 'type' => 'string',
206 'sanitize_callback' => 'sanitize_text_field',
207 ],
208 // OpenAI-compatible endpoint (#721). The URL is not run through
209 // esc_url_raw here: Settings::sanitize_setting() validates it
210 // (scheme, SSRF guard) and save_settings() reports the reason
211 // when it refuses, which a sanitize callback cannot do.
212 'openai_compatible_base_url' => [
213 'type' => 'string',
214 'sanitize_callback' => 'sanitize_text_field',
215 ],
216 'openai_compatible_api_key' => [
217 'type' => 'string',
218 'sanitize_callback' => 'sanitize_text_field',
219 ],
220 'openai_compatible_model' => [
221 'type' => 'string',
222 'sanitize_callback' => 'sanitize_text_field',
223 ],
224 'openai_compatible_timeout' => [
225 'type' => 'integer',
226 'minimum' => 10,
227 'maximum' => 600,
228 'sanitize_callback' => 'absint',
229 'validate_callback' => 'rest_validate_request_arg',
230 ],
231 'openai_compatible_supports_images' => [
232 'type' => 'boolean',
233 ],
234 'openai_compatible_json_mode' => [
235 'type' => 'boolean',
236 ],
237 'openai_compatible_price_per_million' => [
238 'type' => 'number',
239 'minimum' => 0,
240 ],
241 'max_tokens' => [
242 'type' => 'integer',
243 'minimum' => 1,
244 'maximum' => 32000,
245 'sanitize_callback' => 'absint',
246 'validate_callback' => 'rest_validate_request_arg',
247 ],
248 'temperature' => [
249 'type' => 'number',
250 'minimum' => 0,
251 'maximum' => 2,
252 'validate_callback' => 'rest_validate_request_arg',
253 ],
254 'cache_duration' => [
255 'type' => 'integer',
256 'minimum' => 0,
257 'sanitize_callback' => 'absint',
258 'validate_callback' => 'rest_validate_request_arg',
259 ],
260 'keep_data_on_uninstall' => [
261 'type' => 'boolean',
262 'sanitize_callback' => 'rest_sanitize_boolean',
263 ],
264 'enable_mcp' => [
265 'type' => 'boolean',
266 'sanitize_callback' => 'rest_sanitize_boolean',
267 ],
268 'enable_migration_tools' => [
269 'type' => 'boolean',
270 'sanitize_callback' => 'rest_sanitize_boolean',
271 ],
272 'enable_import_export' => [
273 'type' => 'boolean',
274 'sanitize_callback' => 'rest_sanitize_boolean',
275 ],
276 // AI spend controls (#448). `max_requests_per_minute` is not
277 // new, but it was never reachable: registered since 1.0 and
278 // rendered nowhere, so no user could see the throttle that was
279 // limiting them.
280 'max_requests_per_minute' => [
281 'type' => 'integer',
282 'minimum' => 0,
283 'sanitize_callback' => 'absint',
284 'validate_callback' => 'rest_validate_request_arg',
285 ],
286 'ai_daily_request_limit' => [
287 'type' => 'integer',
288 'minimum' => 0,
289 'sanitize_callback' => 'absint',
290 'validate_callback' => 'rest_validate_request_arg',
291 ],
292 'ai_paused' => [
293 'type' => 'boolean',
294 'sanitize_callback' => 'rest_sanitize_boolean',
295 ],
296 ],
297 ]);
298
299
300
301 // Metadata endpoints
302 register_rest_route(self::NAMESPACE, '/metadata/(?P<post_id>\d+)', [
303 'methods' => 'GET',
304 'callback' => [$this, 'get_metadata'],
305 'permission_callback' => [$this, 'check_basic_permissions'],
306 'args' => [
307 'post_id' => [
308 'type' => 'integer',
309 'required' => true,
310 ],
311 ],
312 ]);
313
314 // AI endpoints
315 register_rest_route(self::NAMESPACE, '/ai/generate-metadata', [
316 'methods' => 'POST',
317 'callback' => [$this, 'generate_ai_metadata'],
318 'permission_callback' => [$this, 'check_basic_permissions'],
319 'args' => [
320 'content' => [
321 'type' => 'string',
322 'required' => true,
323 'sanitize_callback' => [$this, 'sanitize_ai_content'],
324 ],
325 'target_keyword' => [
326 'type' => 'string',
327 'sanitize_callback' => 'sanitize_text_field',
328 ],
329 'content_type' => [
330 'type' => 'string',
331 'default' => 'blog_post',
332 'sanitize_callback' => 'sanitize_text_field',
333 ],
334 'tone' => [
335 'type' => 'string',
336 'default' => 'professional',
337 'sanitize_callback' => 'sanitize_text_field',
338 ],
339 'post_id' => [
340 'type' => 'integer',
341 'required' => false,
342 'default' => 0,
343 'sanitize_callback' => 'absint',
344 ],
345 ],
346 ]);
347
348 register_rest_route(self::NAMESPACE, '/ai/improve-title', [
349 'methods' => 'POST',
350 'callback' => [$this, 'improve_ai_title'],
351 'permission_callback' => [$this, 'check_basic_permissions'],
352 'args' => [
353 'content' => [
354 'type' => 'string',
355 'required' => true,
356 'sanitize_callback' => [$this, 'sanitize_ai_content'],
357 ],
358 'current_title' => [
359 'type' => 'string',
360 'sanitize_callback' => 'sanitize_text_field',
361 ],
362 'target_keyword' => [
363 'type' => 'string',
364 'sanitize_callback' => 'sanitize_text_field',
365 ],
366 'content_type' => [
367 'type' => 'string',
368 'default' => 'blog_post',
369 'sanitize_callback' => 'sanitize_text_field',
370 ],
371 'tone' => [
372 'type' => 'string',
373 'default' => 'professional',
374 'sanitize_callback' => 'sanitize_text_field',
375 ],
376 'suggestion' => [
377 'type' => 'string',
378 'sanitize_callback' => 'sanitize_text_field',
379 ],
380 'post_id' => [
381 'type' => 'integer',
382 'required' => false,
383 'default' => 0,
384 'sanitize_callback' => 'absint',
385 ],
386 ],
387 ]);
388
389 register_rest_route(self::NAMESPACE, '/ai/improve-meta-description', [
390 'methods' => 'POST',
391 'callback' => [$this, 'improve_ai_meta_description'],
392 'permission_callback' => [$this, 'check_basic_permissions'],
393 'args' => [
394 'content' => [
395 'type' => 'string',
396 'required' => true,
397 'sanitize_callback' => [$this, 'sanitize_ai_content'],
398 ],
399 'current_description' => [
400 'type' => 'string',
401 'sanitize_callback' => 'sanitize_textarea_field',
402 ],
403 'target_keyword' => [
404 'type' => 'string',
405 'sanitize_callback' => 'sanitize_text_field',
406 ],
407 'content_type' => [
408 'type' => 'string',
409 'default' => 'blog_post',
410 'sanitize_callback' => 'sanitize_text_field',
411 ],
412 'tone' => [
413 'type' => 'string',
414 'default' => 'professional',
415 'sanitize_callback' => 'sanitize_text_field',
416 ],
417 'suggestion' => [
418 'type' => 'string',
419 'sanitize_callback' => 'sanitize_text_field',
420 ],
421 'post_id' => [
422 'type' => 'integer',
423 'required' => false,
424 'default' => 0,
425 'sanitize_callback' => 'absint',
426 ],
427 ],
428 ]);
429
430 register_rest_route(self::NAMESPACE, '/ai/explain-suggestion', [
431 'methods' => 'POST',
432 'callback' => [$this, 'explain_ai_suggestion'],
433 'permission_callback' => [$this, 'check_basic_permissions'],
434 'args' => [
435 'content' => [
436 'type' => 'string',
437 'required' => true,
438 'sanitize_callback' => [$this, 'sanitize_ai_content'],
439 ],
440 'suggestion' => [
441 'type' => 'string',
442 'required' => true,
443 'sanitize_callback' => 'sanitize_text_field',
444 ],
445 'title' => [
446 'type' => 'string',
447 'sanitize_callback' => 'sanitize_text_field',
448 ],
449 'target_keyword' => [
450 'type' => 'string',
451 'sanitize_callback' => 'sanitize_text_field',
452 ],
453 'content_type' => [
454 'type' => 'string',
455 'default' => 'blog_post',
456 'sanitize_callback' => 'sanitize_text_field',
457 ],
458 ],
459 ]);
460
461 register_rest_route(self::NAMESPACE, '/ai/add-dofollow-link', [
462 'methods' => 'POST',
463 'callback' => [$this, 'add_ai_dofollow_link'],
464 'permission_callback' => [$this, 'check_basic_permissions'],
465 'args' => [
466 'content' => [
467 'type' => 'string',
468 'required' => true,
469 'sanitize_callback' => [$this, 'sanitize_ai_content'],
470 ],
471 'target_keyword' => [
472 'type' => 'string',
473 'sanitize_callback' => 'sanitize_text_field',
474 ],
475 'content_type' => [
476 'type' => 'string',
477 'default' => 'blog_post',
478 'sanitize_callback' => 'sanitize_text_field',
479 ],
480 ],
481 ]);
482
483 register_rest_route(self::NAMESPACE, '/ai/add-keyword-paragraph', [
484 'methods' => 'POST',
485 'callback' => [$this, 'add_ai_keyword_paragraph'],
486 'permission_callback' => [$this, 'check_basic_permissions'],
487 'args' => [
488 'content' => [
489 'type' => 'string',
490 'required' => true,
491 'sanitize_callback' => [$this, 'sanitize_ai_content'],
492 ],
493 'target_keyword' => [
494 'type' => 'string',
495 'required' => true,
496 'sanitize_callback' => 'sanitize_text_field',
497 ],
498 'content_type' => [
499 'type' => 'string',
500 'default' => 'blog_post',
501 'sanitize_callback' => 'sanitize_text_field',
502 ],
503 'tone' => [
504 'type' => 'string',
505 'default' => 'professional',
506 'sanitize_callback' => 'sanitize_text_field',
507 ],
508 'word_count' => [
509 'type' => 'integer',
510 'default' => 0,
511 'sanitize_callback' => 'absint',
512 ],
513 'keyword_count' => [
514 'type' => 'integer',
515 'default' => 0,
516 'sanitize_callback' => 'absint',
517 ],
518 ],
519 ]);
520
521 register_rest_route(self::NAMESPACE, '/schema/enable-for-post', [
522 'methods' => 'POST',
523 'callback' => [$this, 'enable_schema_for_post'],
524 'permission_callback' => [$this, 'check_schema_permissions'],
525 'args' => [
526 'post_id' => [
527 'type' => 'integer',
528 'required' => true,
529 'sanitize_callback' => 'absint',
530 ],
531 ],
532 ]);
533
534 register_rest_route(self::NAMESPACE, '/ai/test-connection', [
535 'methods' => 'POST',
536 'callback' => [$this, 'test_ai_connection'],
537 'permission_callback' => [$this, 'check_ai_tools_permissions'],
538 'args' => [
539 'api_key' => [
540 'type' => 'string',
541 'required' => false,
542 'sanitize_callback' => 'sanitize_text_field',
543 ],
544 'provider' => [
545 'type' => 'string',
546 'required' => false,
547 'default' => 'openai',
548 'sanitize_callback' => 'sanitize_key',
549 ],
550 'model' => [
551 'type' => 'string',
552 'required' => false,
553 'sanitize_callback' => 'sanitize_text_field',
554 ],
555 // Only used by the openai_compatible provider: the URL on
556 // screen, so an unsaved endpoint can be tested before saving.
557 'base_url' => [
558 'type' => 'string',
559 'required' => false,
560 'sanitize_callback' => 'sanitize_text_field',
561 ],
562 // Only used by the openai_compatible provider: the JSON mode
563 // toggle on screen. Omitted, the saved setting decides.
564 'json_mode' => [
565 'type' => 'boolean',
566 'required' => false,
567 ],
568 ],
569 ]);
570
571 // Ask an OpenAI-compatible endpoint what models it serves. Ollama, LM
572 // Studio and vLLM all answer GET {base}/models; a gateway that does not
573 // simply leaves the user typing the id by hand (#721).
574 register_rest_route(self::NAMESPACE, '/ai/models', [
575 'methods' => 'POST',
576 'callback' => [$this, 'list_endpoint_models'],
577 'permission_callback' => [$this, 'check_ai_tools_permissions'],
578 'args' => [
579 'base_url' => [
580 'type' => 'string',
581 'required' => false,
582 'sanitize_callback' => 'sanitize_text_field',
583 ],
584 'api_key' => [
585 'type' => 'string',
586 'required' => false,
587 'sanitize_callback' => 'sanitize_text_field',
588 ],
589 ],
590 ]);
591
592 register_rest_route(self::NAMESPACE, '/ai/providers', [
593 'methods' => 'GET',
594 'callback' => [$this, 'get_ai_providers'],
595 'permission_callback' => [$this, 'check_basic_permissions'],
596 ]);
597
598 // Register content brief endpoints
599 $content_brief_endpoint = new \ThinkRank\API\Content_Brief_Endpoint();
600 $content_brief_endpoint->register_routes();
601
602 // Register SEO score endpoints
603 try {
604 $database = new \ThinkRank\Core\Database();
605 $seo_calculator = new \ThinkRank\AI\SEOScoreCalculator($database);
606 $seo_score_endpoint = new \ThinkRank\API\SEOScoreEndpoint($seo_calculator);
607 $seo_score_endpoint->register_routes();
608 } catch (\Exception $e) {
609 // SEO Score endpoint registration failed
610 }
611
612 // Register Usage Analytics endpoints
613 try {
614 $usage_analytics_endpoint = new \ThinkRank\API\Usage_Analytics_Endpoint();
615 $usage_analytics_endpoint->register_routes();
616 } catch (\Exception $e) {
617 // Usage Analytics endpoint registration failed
618 }
619
620 // Register Site SEO Analyzer endpoint
621 try {
622 $seo_analyzer_endpoint = new \ThinkRank\API\SEO_Analyzer_Endpoint();
623 $seo_analyzer_endpoint->register_routes();
624 } catch (\Exception $e) {
625 // Site SEO Analyzer endpoint registration failed
626 }
627
628 // Register SEO Analytics endpoints
629 try {
630 $seo_analytics_endpoint = new \ThinkRank\API\SEO_Analytics_Endpoint();
631 $seo_analytics_endpoint->register_routes();
632 } catch (\Exception $e) {
633 // Failed to register SEO Analytics endpoint
634 }
635
636 // Register Instant Indexing endpoints
637 try {
638 $instant_indexing_endpoint = new \ThinkRank\API\Instant_Indexing_Endpoint();
639 $instant_indexing_endpoint->register_routes();
640 } catch (\Exception $e) {
641 // Failed to register Instant Indexing endpoint
642 }
643
644 // Register Pillar Content endpoints
645 try {
646 $pillar_content_endpoint = new \ThinkRank\API\Pillar_Content_Endpoint();
647 $pillar_content_endpoint->register_routes();
648 } catch (\Exception $e) {
649 // Failed to register Pillar Content endpoint
650 }
651
652 // Register Focus Keyword Usage endpoint ("already used" status).
653 try {
654 $focus_keyword_usage_endpoint = new \ThinkRank\API\Focus_Keyword_Usage_Endpoint();
655 $focus_keyword_usage_endpoint->register_routes();
656 } catch (\Exception $e) {
657 // Failed to register Focus Keyword Usage endpoint
658 }
659 // Register Global Robot Meta endpoints
660 try {
661 $global_robot_meta_endpoint = new \ThinkRank\API\Global_Robot_Meta_Endpoint();
662 $global_robot_meta_endpoint->register_routes();
663 } catch (\Exception $e) {
664 // Failed to register Global Robot Meta endpoint
665 }
666
667 // Register Author Archives endpoints
668 try {
669 $author_archives_endpoint = new \ThinkRank\API\Author_Archives_Endpoint();
670 $author_archives_endpoint->register_routes();
671 } catch (\Exception $e) {
672 // Failed to register Author Archives endpoint
673 }
674
675 // Register Role Manager endpoint
676 try {
677 $role_manager_endpoint = new \ThinkRank\API\Role_Manager_Endpoint();
678 $role_manager_endpoint->register_routes();
679 } catch (\Exception $e) {
680 // Failed to register Role Manager endpoint
681 }
682
683 // Register Email Report endpoints
684 try {
685 $email_report_endpoint = new \ThinkRank\API\Email_Report_Endpoint();
686 $email_report_endpoint->register_routes();
687 } catch (\Exception $e) {
688 // Failed to register Email Report endpoint
689 }
690
691
692 register_rest_route(self::NAMESPACE, '/ai/status', [
693 'methods' => 'GET',
694 'callback' => [$this, 'get_ai_status'],
695 'permission_callback' => [$this, 'check_basic_permissions'],
696 ]);
697
698 register_rest_route(self::NAMESPACE, '/ai/analyze-content', [
699 'methods' => 'POST',
700 'callback' => [$this, 'analyze_content'],
701 'permission_callback' => [$this, 'check_basic_permissions'],
702 'args' => [
703 'content' => [
704 'type' => 'string',
705 'required' => true,
706 'sanitize_callback' => [$this, 'sanitize_ai_content'],
707 ],
708 'metadata' => [
709 'type' => 'object',
710 'required' => false,
711 'sanitize_callback' => [$this, 'sanitize_metadata_object'],
712 ],
713 'post_id' => [
714 'type' => 'integer',
715 'required' => false,
716 'sanitize_callback' => 'absint',
717 ],
718 ],
719 ]);
720 }
721
722 /**
723 * Check basic permissions (for logged-in users)
724 *
725 * @param \WP_REST_Request $request Request object
726 * @return bool|WP_Error Permission status
727 */
728 public function check_basic_permissions(\WP_REST_Request $request) {
729 // Allow access for logged-in users who can edit posts
730 if (!is_user_logged_in()) {
731 return new \WP_Error(
732 'rest_forbidden',
733 __('You must be logged in to access this endpoint.', 'thinkrank'),
734 ['status' => 401]
735 );
736 }
737
738 if (!current_user_can('edit_posts')) {
739 return new \WP_Error(
740 'rest_forbidden',
741 __('You do not have permission to access this endpoint.', 'thinkrank'),
742 ['status' => 403]
743 );
744 }
745
746 return true;
747 }
748
749
750 /**
751 * Simple transient-based rate limiter
752 *
753 * @param string $bucket_id Unique bucket per user/IP and route
754 * @param int $limit Max requests per minute
755 * @return bool|\WP_Error True if allowed, or WP_Error when rate limited
756 */
757 private function enforce_rate_limit(string $bucket_id, int $limit) {
758 $settings = \ThinkRank\Core\Settings::instance();
759 $enabled = (bool) $settings->get('enable_rate_limiting', true);
760 if (!$enabled) {
761 return true;
762 }
763 // A non-positive limit means unlimited, matching AI\Manager and the
764 // label on the control (#448). This used to fall through to
765 // max(1, $limit) below, which turned a 0 into the most restrictive
766 // setting available rather than the least: the first request of each
767 // minute was allowed and every other one got a 429. Harmless while the
768 // field was rendered nowhere, user-facing the moment it was surfaced.
769 if ($limit <= 0) {
770 return true;
771 }
772 $now = time();
773 $window = 60;
774 $key = 'thinkrank_rl_' . md5($bucket_id);
775 $bucket = get_transient($key);
776 if (!is_array($bucket)) {
777 $bucket = ['start' => $now, 'count' => 0];
778 }
779 if ($now - ($bucket['start'] ?? 0) >= $window) {
780 $bucket = ['start' => $now, 'count' => 0];
781 }
782 if (($bucket['count'] ?? 0) >= $limit) {
783 return new \WP_Error('rate_limited', __('Rate limit exceeded. Please wait a moment and try again.', 'thinkrank'), ['status' => 429]);
784 }
785 $bucket['count']++;
786 set_transient($key, $bucket, $window);
787 return true;
788 }
789
790 /**
791 * Check admin permissions (for settings)
792 *
793 * @param \WP_REST_Request $request Request object
794 * @return bool|WP_Error Permission status
795 */
796 public function check_admin_permissions(\WP_REST_Request $request) {
797 // Allow access for administrators only
798 if (!is_user_logged_in()) {
799 return new \WP_Error(
800 'rest_forbidden',
801 __('You must be logged in to access this endpoint.', 'thinkrank'),
802 ['status' => 401]
803 );
804 }
805
806 if (!current_user_can('manage_options')) {
807 return new \WP_Error(
808 'rest_forbidden',
809 __('You do not have permission to manage settings.', 'thinkrank'),
810 ['status' => 403]
811 );
812 }
813
814 return true;
815 }
816
817 /**
818 * Check Settings section permissions.
819 *
820 * Delegable via Role Manager: passes for administrators (bypass) and for
821 * any role granted the `thinkrank_settings` capability. Used by the core
822 * /settings routes so the "Settings & API Keys" area can be delegated.
823 *
824 * @param \WP_REST_Request $request Request object
825 * @return bool|\WP_Error Permission status
826 */
827 public function check_settings_permissions(\WP_REST_Request $request) {
828 if (!is_user_logged_in()) {
829 return new \WP_Error(
830 'rest_forbidden',
831 __('You must be logged in to access this endpoint.', 'thinkrank'),
832 ['status' => 401]
833 );
834 }
835
836 if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings')) {
837 return new \WP_Error(
838 'rest_forbidden',
839 __('You do not have permission to manage ThinkRank settings.', 'thinkrank'),
840 ['status' => 403]
841 );
842 }
843
844 return true;
845 }
846
847 /**
848 * Check Schema Manager section permissions.
849 *
850 * Delegable via Role Manager: route_map() maps the `schema` prefix to
851 * thinkrank_schema. /schema/enable-for-post is what the editor's "enable
852 * structured data" suggestion posts to, so gating it on manage_options made
853 * that button fail for exactly the roles the Schema grant was meant to
854 * serve (#844).
855 *
856 * @param \WP_REST_Request $request Request object
857 * @return bool|\WP_Error Permission status
858 */
859 public function check_schema_permissions(\WP_REST_Request $request) {
860 return $this->check_mapped_capability('thinkrank_schema');
861 }
862
863 /**
864 * Check AI Tools section permissions.
865 *
866 * Delegable via Role Manager: route_map() maps the `ai` prefix to
867 * thinkrank_content_tools. Testing a provider and listing its models are
868 * configuration reads that report whether the stored key works; neither
869 * returns the key.
870 *
871 * @param \WP_REST_Request $request Request object
872 * @return bool|\WP_Error Permission status
873 */
874 public function check_ai_tools_permissions(\WP_REST_Request $request) {
875 return $this->check_mapped_capability('thinkrank_content_tools');
876 }
877
878 /**
879 * Logged in, and holding a ThinkRank capability from the map.
880 *
881 * One body for the per-section callbacks above so they cannot drift apart
882 * the way the hardcoded manage_options checks drifted from the map.
883 * Administrators are unaffected: Role_Manager grants every ThinkRank
884 * capability to manage_options holders through `user_has_cap`.
885 *
886 * @param string $capability ThinkRank capability slug.
887 * @return bool|\WP_Error Permission status
888 */
889 private function check_mapped_capability(string $capability) {
890 if (!is_user_logged_in()) {
891 return new \WP_Error(
892 'rest_forbidden',
893 __('You must be logged in to access this endpoint.', 'thinkrank'),
894 ['status' => 401]
895 );
896 }
897
898 if (!\ThinkRank\Core\Capability_Manager::current_user_can($capability)) {
899 return new \WP_Error(
900 'rest_forbidden',
901 __('You do not have permission to access this ThinkRank feature.', 'thinkrank'),
902 ['status' => 403]
903 );
904 }
905
906 return true;
907 }
908
909 /**
910 * Get user capabilities
911 *
912 * @param \WP_REST_Request $request Request object
913 * @return \WP_REST_Response Response object
914 */
915 public function get_capabilities(\WP_REST_Request $request): \WP_REST_Response {
916 return new \WP_REST_Response([
917 'manage_settings' => current_user_can('manage_options'),
918 'view_analytics' => current_user_can('edit_posts'),
919
920 'use_ai_features' => current_user_can('edit_posts'),
921 ]);
922 }
923
924 /**
925 * Get plugin information
926 *
927 * @param \WP_REST_Request $request Request object
928 * @return \WP_REST_Response Response object
929 */
930 public function get_plugin_info(\WP_REST_Request $request): \WP_REST_Response {
931 return new \WP_REST_Response([
932 'version' => THINKRANK_VERSION,
933 'name' => 'ThinkRank',
934 'description' => 'AI-native SEO plugin for WordPress',
935 ]);
936 }
937
938 /**
939 * Get system status
940 *
941 * @param \WP_REST_Request $request Request object
942 * @return \WP_REST_Response Response object
943 */
944 public function get_system_status(\WP_REST_Request $request): \WP_REST_Response {
945 return new \WP_REST_Response([
946 'status' => 'healthy',
947 'issues' => [],
948 'php_version' => PHP_VERSION,
949 'wp_version' => get_bloginfo('version'),
950 ]);
951 }
952
953 /**
954 * Get ThinkRank integration health for MCP/Abilities clients (see #188).
955 *
956 * Admin-gated diagnostic; never returns secret material. Delegates to the
957 * shared reporter so the ability and this route stay in lock-step.
958 *
959 * @param \WP_REST_Request $request Request object
960 * @return \WP_REST_Response Response object
961 */
962 public function get_connection_status(\WP_REST_Request $request): \WP_REST_Response {
963 return new \WP_REST_Response(\ThinkRank\Diagnostics\Connection_Status::report());
964 }
965
966
967
968 /**
969 * Get settings
970 *
971 * @param \WP_REST_Request $request Request object
972 * @return \WP_REST_Response Response object
973 */
974 public function get_settings(\WP_REST_Request $request): \WP_REST_Response {
975 // Use Settings class for consistent access (handles decryption automatically)
976 $settings_instance = \ThinkRank\Core\Settings::instance();
977
978 $settings = [
979 'ai_provider' => $settings_instance->get('ai_provider', \ThinkRank\Core\Settings::AI_PROVIDER_NONE),
980 'openai_api_key' => $settings_instance->get('openai_api_key', ''),
981 'openai_model' => $settings_instance->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL),
982 'claude_api_key' => $settings_instance->get('claude_api_key', ''),
983 'claude_model' => $settings_instance->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL),
984 'gemini_api_key' => $settings_instance->get('gemini_api_key', ''),
985 'gemini_model' => $settings_instance->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL),
986 'openrouter_api_key' => $settings_instance->get('openrouter_api_key', ''),
987 'openrouter_model' => $settings_instance->get('openrouter_model', \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL),
988 'openai_compatible_base_url' => $settings_instance->get('openai_compatible_base_url', ''),
989 'openai_compatible_api_key' => $settings_instance->get('openai_compatible_api_key', ''),
990 'openai_compatible_model' => $settings_instance->get('openai_compatible_model', ''),
991 'openai_compatible_timeout' => (int) $settings_instance->get('openai_compatible_timeout', \ThinkRank\Core\Settings::DEFAULT_OPENAI_COMPATIBLE_TIMEOUT),
992 'openai_compatible_supports_images' => (bool) $settings_instance->get('openai_compatible_supports_images', false),
993 'openai_compatible_json_mode' => (bool) $settings_instance->get('openai_compatible_json_mode', false),
994 'openai_compatible_price_per_million' => (float) $settings_instance->get('openai_compatible_price_per_million', 0),
995 'max_tokens' => $settings_instance->get('max_tokens', 1000),
996 'temperature' => $settings_instance->get('temperature', 0.7),
997 'cache_duration' => $settings_instance->get('cache_duration', 3600),
998 'keep_data_on_uninstall' => (bool) $settings_instance->get('keep_data_on_uninstall', true),
999 'enable_migration_tools' => (bool) $settings_instance->get('enable_migration_tools', false),
1000 'enable_import_export' => (bool) $settings_instance->get('enable_import_export', false),
1001 'google_account_connected' => (bool) $settings_instance->get('google_account_connected', false),
1002 'enable_mcp' => (bool) $settings_instance->get('enable_mcp', false),
1003 'max_requests_per_minute' => (int) $settings_instance->get('max_requests_per_minute', 0),
1004 'ai_daily_request_limit' => (int) $settings_instance->get('ai_daily_request_limit', 0),
1005 'ai_paused' => (bool) $settings_instance->get('ai_paused', false),
1006 ];
1007
1008
1009
1010 // Don't send full API keys to frontend for security - mask them,
1011 // revealing the first 5 and last 3 chars so the saved key is recognizable.
1012 if (!empty($settings['openai_api_key'])) {
1013 $settings['openai_api_key'] = $this->mask_ai_api_key($settings['openai_api_key']);
1014 }
1015 if (!empty($settings['claude_api_key'])) {
1016 $settings['claude_api_key'] = $this->mask_ai_api_key($settings['claude_api_key']);
1017 }
1018 if (!empty($settings['gemini_api_key'])) {
1019 $settings['gemini_api_key'] = $this->mask_ai_api_key($settings['gemini_api_key']);
1020 }
1021 if (!empty($settings['openrouter_api_key'])) {
1022 $settings['openrouter_api_key'] = $this->mask_ai_api_key($settings['openrouter_api_key']);
1023 }
1024 if (!empty($settings['openai_compatible_api_key'])) {
1025 $settings['openai_compatible_api_key'] = $this->mask_ai_api_key($settings['openai_compatible_api_key']);
1026 }
1027
1028 return new \WP_REST_Response($settings);
1029 }
1030
1031 /**
1032 * Mask an AI provider API key for display.
1033 *
1034 * Reveals the first 5 and last 3 characters with a bullet run in between
1035 * (e.g. "sk-pr••••••••abc"). Keys of 8 chars or fewer are fully masked so
1036 * head + tail can't reconstruct the whole value. The "••••••••" sentinel is
1037 * what save_settings() looks for to skip re-saving a resubmitted mask.
1038 *
1039 * @param string $key Raw API key.
1040 * @return string Masked key safe to send to the frontend.
1041 */
1042 private function mask_ai_api_key(string $key): string {
1043 if (strlen($key) <= 8) {
1044 return '••••••••';
1045 }
1046
1047 return substr($key, 0, 5) . '••••••••' . substr($key, -3);
1048 }
1049
1050 /**
1051 * Save settings
1052 *
1053 * @param \WP_REST_Request $request Request object
1054 * @return \WP_REST_Response Response object
1055 */
1056 public function save_settings(\WP_REST_Request $request): \WP_REST_Response {
1057 $params = $request->get_params();
1058
1059 // Get Settings instance for proper encryption handling
1060 $settings = \ThinkRank\Core\Settings::instance();
1061
1062 // Capture the pre-save MCP state so we can detect an on/off transition
1063 // below and mint/revoke the connection token to match (see #244).
1064 $mcp_was_enabled = (bool) $settings->get('enable_mcp', false);
1065
1066 // Pointing the site's AI at an arbitrary host — including loopback and
1067 // LAN addresses, which this provider deliberately allows — is an
1068 // administrator's decision, not a delegated one. The settings route
1069 // itself is delegable through the Role Manager's `thinkrank_settings`
1070 // capability, so an editor granted "manage ThinkRank settings" could
1071 // otherwise aim server-side requests (with an Authorization header of
1072 // their choosing) at internal services. Every other field on this route
1073 // stays delegable; only these are held back (#721).
1074 $endpoint_fields = [
1075 'openai_compatible_base_url',
1076 'openai_compatible_api_key',
1077 'openai_compatible_model',
1078 'openai_compatible_timeout',
1079 'openai_compatible_supports_images',
1080 'openai_compatible_json_mode',
1081 'openai_compatible_price_per_million',
1082 ];
1083
1084 foreach ($endpoint_fields as $endpoint_field) {
1085 if (!isset($params[$endpoint_field])) {
1086 continue;
1087 }
1088
1089 // Only an actual change needs the capability: a client that echoes
1090 // the whole settings payload back unchanged is not reconfiguring
1091 // anything, and failing that save would break the Settings screen
1092 // for delegated users editing an unrelated field.
1093 //
1094 // The key needs the mask rule the persistence loop below already
1095 // uses. GET /settings returns it masked ("sk-pr••••••••abc"), so
1096 // comparing that against the stored plaintext always differs, and
1097 // every echoed payload would read as "an administrator changed the
1098 // key" — locking delegated users out of saving anything at all.
1099 $submitted = $params[$endpoint_field];
1100 if (is_string($submitted) && false !== strpos($submitted, '••••••••')) {
1101 continue;
1102 }
1103
1104 $stored = $settings->get($endpoint_field);
1105
1106 // Booleans and numbers arrive typed from the REST layer but are
1107 // stored as '1'/'' and '120'; compare them as the values they are.
1108 if (is_bool($submitted) || is_bool($stored)) {
1109 if ((bool) $stored === (bool) $submitted) {
1110 continue;
1111 }
1112 } elseif (is_numeric($submitted) && is_numeric($stored)) {
1113 if ((float) $stored === (float) $submitted) {
1114 continue;
1115 }
1116 } elseif ((string) $stored === (string) $submitted) {
1117 continue;
1118 }
1119
1120 if (!current_user_can('manage_options')) {
1121 return new \WP_REST_Response([
1122 'success' => false,
1123 'message' => __('Only an administrator can configure a custom AI endpoint.', 'thinkrank'),
1124 'field' => $endpoint_field,
1125 ], 403);
1126 }
1127
1128 break;
1129 }
1130
1131 // Selecting the provider is the same decision by another name.
1132 if (isset($params['ai_provider'])
1133 && 'openai_compatible' === $params['ai_provider']
1134 && 'openai_compatible' !== (string) $settings->get('ai_provider', \ThinkRank\Core\Settings::AI_PROVIDER_NONE)
1135 && !current_user_can('manage_options')
1136 ) {
1137 return new \WP_REST_Response([
1138 'success' => false,
1139 'message' => __('Only an administrator can configure a custom AI endpoint.', 'thinkrank'),
1140 'field' => 'ai_provider',
1141 ], 403);
1142 }
1143
1144 // A refused endpoint URL has to say why. Settings::sanitize_setting()
1145 // stores '' for one that fails validation — right, since an unvalidated
1146 // URL must never become a URL we fetch — but silent, so the user would
1147 // see "Settings saved" and an endpoint that vanished. Validate here,
1148 // where the reason can be returned, and reject the whole save: a
1149 // half-applied AI provider is worse than none (#721).
1150 if (!empty($params['openai_compatible_base_url'])) {
1151 $validated_base_url = \ThinkRank\AI\Endpoint_URL_Validator::validate((string) $params['openai_compatible_base_url']);
1152 if (is_wp_error($validated_base_url)) {
1153 return new \WP_REST_Response([
1154 'success' => false,
1155 'message' => $validated_base_url->get_error_message(),
1156 'field' => 'openai_compatible_base_url',
1157 ], 400);
1158 }
1159
1160 $params['openai_compatible_base_url'] = $validated_base_url;
1161 }
1162
1163 // Map frontend parameter names to setting keys
1164 $settings_map = [
1165 'ai_provider' => 'ai_provider',
1166 'openai_api_key' => 'openai_api_key',
1167 'openai_model' => 'openai_model',
1168 'claude_api_key' => 'claude_api_key',
1169 'claude_model' => 'claude_model',
1170 'gemini_api_key' => 'gemini_api_key',
1171 'gemini_model' => 'gemini_model',
1172 'openrouter_api_key' => 'openrouter_api_key',
1173 'openrouter_model' => 'openrouter_model',
1174 'openai_compatible_base_url' => 'openai_compatible_base_url',
1175 'openai_compatible_api_key' => 'openai_compatible_api_key',
1176 'openai_compatible_model' => 'openai_compatible_model',
1177 'openai_compatible_timeout' => 'openai_compatible_timeout',
1178 'openai_compatible_supports_images' => 'openai_compatible_supports_images',
1179 'openai_compatible_json_mode' => 'openai_compatible_json_mode',
1180 'openai_compatible_price_per_million' => 'openai_compatible_price_per_million',
1181 'max_tokens' => 'max_tokens',
1182 'temperature' => 'temperature',
1183 'cache_duration' => 'cache_duration',
1184 'keep_data_on_uninstall' => 'keep_data_on_uninstall',
1185 'enable_mcp' => 'enable_mcp',
1186 'enable_migration_tools' => 'enable_migration_tools',
1187 'enable_import_export' => 'enable_import_export',
1188 'max_requests_per_minute' => 'max_requests_per_minute',
1189 'ai_daily_request_limit' => 'ai_daily_request_limit',
1190 'ai_paused' => 'ai_paused',
1191 ];
1192
1193 // Processing settings save request
1194
1195 foreach ($settings_map as $param_key => $setting_key) {
1196 if (isset($params[$param_key])) {
1197 $value = $params[$param_key];
1198
1199 // Handle API keys specially - check for masked values
1200 if (in_array($param_key, ['openai_api_key', 'claude_api_key', 'gemini_api_key', 'openrouter_api_key', 'openai_compatible_api_key'], true)) {
1201 // Don't update if the value carries the mask sentinel (the
1202 // preview now keeps real head/tail chars around it, so match
1203 // anywhere rather than only at the start). Empty still clears.
1204 if (strpos($value, '••••••••') !== false) {
1205 continue;
1206 }
1207 }
1208
1209 // Use Settings class for all operations (handles encryption automatically)
1210 // A failed write is skipped rather than aborting the batch, so
1211 // one bad setting cannot block the rest of the save.
1212 $settings->set($setting_key, $value);
1213 }
1214 }
1215
1216 // MCP is a single master switch (see #244): enabling it auto-mints a
1217 // read/write connection token so the connect recipes are ready without
1218 // a separate "Generate token" step.
1219 //
1220 // Disabling is a PAUSE, not a wipe: the switch alone already denies all
1221 // access (Mcp_Server 403s and the OAuth/discovery endpoints refuse while
1222 // off), so stored tokens and OAuth grants are inert. We keep them so
1223 // re-enabling restores every previously connected app with no
1224 // re-approval. Explicit revocation stays available per-app (the
1225 // Connected AI apps trash button) and for the shared token (Reset
1226 // token / rotate). Only act on an actual on->off->on transition so
1227 // saving unrelated settings never touches the connection.
1228 if (isset($params['enable_mcp'])) {
1229 $mcp_now_enabled = (bool) $settings->get('enable_mcp', false);
1230 if ($mcp_now_enabled && !$mcp_was_enabled) {
1231 \ThinkRank\Mcp\Mcp_Pairing::connect();
1232 }
1233 }
1234
1235 // Auto-dismiss welcome notice if API key was saved
1236 $this->maybe_dismiss_welcome_notice($params);
1237
1238 // Force AI Manager to re-initialize client with new settings
1239 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']) || isset($params['openai_compatible_base_url']) || isset($params['openai_compatible_api_key']) || isset($params['openai_compatible_model'])) {
1240 // Clear any cached AI Manager instances to force re-initialization
1241 wp_cache_delete('thinkrank_ai_manager', 'thinkrank');
1242
1243 // If we have an AI Manager instance, force it to re-initialize
1244 try {
1245 $ai_manager = new \ThinkRank\AI\Manager($settings);
1246 $ai_manager->reinitialize_client();
1247 } catch (\Exception $e) {
1248 // Ignore initialization errors at this point
1249 }
1250 }
1251
1252 return new \WP_REST_Response([
1253 'success' => true,
1254 'message' => __('Settings saved successfully', 'thinkrank'),
1255 'settings' => $this->get_settings($request)->get_data(),
1256 ]);
1257 }
1258
1259 /**
1260 * Maybe dismiss welcome notice if API key was saved
1261 *
1262 * @param array $params Request parameters
1263 * @return void
1264 */
1265 private function maybe_dismiss_welcome_notice(array $params): void {
1266 // Check if an API key was saved (not cleared)
1267 $api_key_saved = false;
1268
1269 if (!empty($params['openai_api_key']) && $params['openai_api_key'] !== '') {
1270 $api_key_saved = true;
1271 }
1272
1273 if (!empty($params['claude_api_key']) && $params['claude_api_key'] !== '') {
1274 $api_key_saved = true;
1275 }
1276
1277 // Auto-dismiss welcome notice if API key was configured
1278 if ($api_key_saved && get_option('thinkrank_show_welcome')) {
1279 delete_option('thinkrank_show_welcome');
1280 }
1281 }
1282
1283 /**
1284 * Get metadata for post
1285 *
1286 * @param \WP_REST_Request $request Request object
1287 * @return \WP_REST_Response Response object
1288 */
1289 public function get_metadata(\WP_REST_Request $request): \WP_REST_Response {
1290 $post_id = (int) $request->get_param('post_id');
1291
1292 // Object-level guard: only expose a post's stored SEO meta to a user who
1293 // can edit that specific post (the section capability gate handles the
1294 // AI Tools toggle; this adds per-post ownership).
1295 if (!current_user_can('edit_post', $post_id)) {
1296 return new \WP_REST_Response(['message' => 'You are not allowed to view this metadata.'], 403);
1297 }
1298
1299 // Read the pending flag BEFORE the meta below, never after. A writer
1300 // that finishes mid-request writes the meta and *then* clears the
1301 // flag; reading the flag last could therefore observe "no value" and
1302 // "not pending" for the same run and stop the editor panel polling one
1303 // tick before the value it was waiting for lands (#329).
1304 $pending = \ThinkRank\SEO\Metadata_Pending::is_pending($post_id);
1305
1306 // Get existing metadata. These must read the same canonical meta keys
1307 // the rest of the plugin writes/reads (frontend, metabox, scoring),
1308 // otherwise the response is always empty:
1309 // title/description → _thinkrank_seo_title / _thinkrank_meta_description
1310 // keywords → Focus_Keywords (stored as _thinkrank_focus_keywords)
1311 // last_generated → _thinkrank_generated_at (written by Metadata_Generator)
1312 $metadata = [
1313 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true),
1314 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true),
1315 'keywords' => \ThinkRank\SEO\Focus_Keywords::get($post_id),
1316 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true) ?: 0,
1317 'last_generated' => get_post_meta($post_id, '_thinkrank_generated_at', true),
1318 // Whether a background writer (Auto AI on publish, bulk
1319 // optimization, imports) is about to fill these fields. The editor
1320 // panel polls only while this is true.
1321 'pending' => $pending,
1322 ];
1323
1324 return new \WP_REST_Response($metadata);
1325 }
1326
1327 /**
1328 * Generate AI-powered SEO metadata
1329 *
1330 * @param \WP_REST_Request $request Request object containing content and generation options
1331 * @return \WP_REST_Response Response object with generated metadata or error message
1332 * @throws \Exception When AI metadata generation fails or AI client is unavailable
1333 */
1334 public function generate_ai_metadata(\WP_REST_Request $request): \WP_REST_Response {
1335 $content = $request->get_param('content');
1336 $options = [
1337 'target_keyword' => $request->get_param('target_keyword'),
1338 'content_type' => $request->get_param('content_type'),
1339 'tone' => $request->get_param('tone'),
1340 // Instruct the model to write in the post/site language instead of
1341 // defaulting to English on non-English sites (issue #234).
1342 'language' => \ThinkRank\AI\Language_Resolver::resolve((int) $request->get_param('post_id')),
1343 ];
1344
1345 // Rate limiting: per user/IP per route
1346 $user_id = get_current_user_id();
1347 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1348 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1349 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1350 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1351 if (is_wp_error($allowed)) {
1352 return new \WP_REST_Response([
1353 'success' => false,
1354 'message' => $allowed->get_error_message(),
1355 ], $allowed->get_error_data()['status'] ?? 429);
1356 }
1357
1358 try {
1359 // Get AI manager instance
1360 $ai_manager = new \ThinkRank\AI\Manager();
1361 $ai_manager->initialize_client();
1362
1363 // Run through the generator so title/description are capped to the
1364 // configured limits and the character counts are returned.
1365 $generator = new \ThinkRank\AI\Metadata_Generator($ai_manager);
1366 $metadata = $generator->generate_for_content($content, $options);
1367
1368 return new \WP_REST_Response([
1369 'success' => true,
1370 'data' => $metadata,
1371 'message' => __('SEO metadata generated successfully', 'thinkrank'),
1372 ]);
1373 } catch (\Exception $e) {
1374 return new \WP_REST_Response([
1375 'success' => false,
1376 'message' => $e->getMessage(),
1377 ], 400);
1378 }
1379 }
1380
1381 /**
1382 * Generate and return an improved SEO title for an "Apply" suggestion action.
1383 *
1384 * @param \WP_REST_Request $request Request object.
1385 * @return \WP_REST_Response Response with the improved title under data.title.
1386 * @throws \Exception When title improvement fails or the AI client is unavailable.
1387 */
1388 public function improve_ai_title(\WP_REST_Request $request): \WP_REST_Response {
1389 $content = $request->get_param('content');
1390 $options = [
1391 'current_title' => $request->get_param('current_title'),
1392 'target_keyword' => $request->get_param('target_keyword'),
1393 'content_type' => $request->get_param('content_type'),
1394 'tone' => $request->get_param('tone'),
1395 'suggestion' => $request->get_param('suggestion'),
1396 'language' => \ThinkRank\AI\Language_Resolver::resolve((int) $request->get_param('post_id')),
1397 ];
1398
1399 // Rate limiting: shares the AI generation bucket.
1400 $user_id = get_current_user_id();
1401 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1402 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1403 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1404 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1405 if (is_wp_error($allowed)) {
1406 return new \WP_REST_Response([
1407 'success' => false,
1408 'message' => $allowed->get_error_message(),
1409 ], $allowed->get_error_data()['status'] ?? 429);
1410 }
1411
1412 try {
1413 $ai_manager = new \ThinkRank\AI\Manager();
1414 $ai_manager->initialize_client();
1415
1416 $result = $ai_manager->improve_seo_title($content, $options);
1417
1418 return new \WP_REST_Response([
1419 'success' => true,
1420 'data' => $result,
1421 'message' => __('SEO title improved successfully', 'thinkrank'),
1422 ]);
1423 } catch (\Exception $e) {
1424 return new \WP_REST_Response([
1425 'success' => false,
1426 'message' => $e->getMessage(),
1427 ], 400);
1428 }
1429 }
1430
1431 /**
1432 * Generate and return an improved meta description for an "Apply" action.
1433 *
1434 * @param \WP_REST_Request $request Request object.
1435 * @return \WP_REST_Response Response with the description under data.description.
1436 * @throws \Exception When generation fails or the AI client is unavailable.
1437 */
1438 public function improve_ai_meta_description(\WP_REST_Request $request): \WP_REST_Response {
1439 $content = $request->get_param('content');
1440 $options = [
1441 'current_description' => $request->get_param('current_description'),
1442 'target_keyword' => $request->get_param('target_keyword'),
1443 'content_type' => $request->get_param('content_type'),
1444 'tone' => $request->get_param('tone'),
1445 'suggestion' => $request->get_param('suggestion'),
1446 'language' => \ThinkRank\AI\Language_Resolver::resolve((int) $request->get_param('post_id')),
1447 ];
1448
1449 // Rate limiting: shares the AI generation bucket.
1450 $user_id = get_current_user_id();
1451 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1452 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1453 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1454 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1455 if (is_wp_error($allowed)) {
1456 return new \WP_REST_Response([
1457 'success' => false,
1458 'message' => $allowed->get_error_message(),
1459 ], $allowed->get_error_data()['status'] ?? 429);
1460 }
1461
1462 try {
1463 $ai_manager = new \ThinkRank\AI\Manager();
1464 $ai_manager->initialize_client();
1465
1466 $result = $ai_manager->improve_meta_description($content, $options);
1467
1468 return new \WP_REST_Response([
1469 'success' => true,
1470 'data' => $result,
1471 'message' => __('Meta description generated successfully', 'thinkrank'),
1472 ]);
1473 } catch (\Exception $e) {
1474 return new \WP_REST_Response([
1475 'success' => false,
1476 'message' => $e->getMessage(),
1477 ], 400);
1478 }
1479 }
1480
1481 /**
1482 * Explain a single SEO suggestion in plain, post-specific language.
1483 *
1484 * Read-only copilot action: returns a short AI explanation of why the
1485 * suggestion matters for this post and how to resolve it. Does not modify
1486 * any content.
1487 *
1488 * @param \WP_REST_Request $request Request object.
1489 * @return \WP_REST_Response Response with the explanation under data.explanation.
1490 * @throws \Exception When generation fails or the AI client is unavailable.
1491 */
1492 public function explain_ai_suggestion(\WP_REST_Request $request): \WP_REST_Response {
1493 $content = $request->get_param('content');
1494 $options = [
1495 'suggestion' => $request->get_param('suggestion'),
1496 'title' => $request->get_param('title'),
1497 'target_keyword' => $request->get_param('target_keyword'),
1498 'content_type' => $request->get_param('content_type'),
1499 ];
1500
1501 // Rate limiting: shares the AI generation bucket.
1502 $user_id = get_current_user_id();
1503 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1504 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1505 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1506 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1507 if (is_wp_error($allowed)) {
1508 return new \WP_REST_Response([
1509 'success' => false,
1510 'message' => $allowed->get_error_message(),
1511 ], $allowed->get_error_data()['status'] ?? 429);
1512 }
1513
1514 try {
1515 $ai_manager = new \ThinkRank\AI\Manager();
1516 $ai_manager->initialize_client();
1517
1518 $result = $ai_manager->explain_seo_suggestion($content, $options);
1519
1520 return new \WP_REST_Response([
1521 'success' => true,
1522 'data' => $result,
1523 'message' => __('Explanation generated successfully', 'thinkrank'),
1524 ]);
1525 } catch (\Exception $e) {
1526 return new \WP_REST_Response([
1527 'success' => false,
1528 'message' => $e->getMessage(),
1529 ], 400);
1530 }
1531 }
1532
1533 /**
1534 * Generate a content fragment with one authoritative external dofollow link.
1535 *
1536 * @param \WP_REST_Request $request Request object.
1537 * @return \WP_REST_Response Response with the HTML fragment under data.html.
1538 * @throws \Exception When generation fails or the AI client is unavailable.
1539 */
1540 public function add_ai_dofollow_link(\WP_REST_Request $request): \WP_REST_Response {
1541 $content = $request->get_param('content');
1542 $options = [
1543 'target_keyword' => $request->get_param('target_keyword'),
1544 'content_type' => $request->get_param('content_type'),
1545 ];
1546
1547 // Rate limiting: shares the AI generation bucket.
1548 $user_id = get_current_user_id();
1549 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1550 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1551 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1552 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1553 if (is_wp_error($allowed)) {
1554 return new \WP_REST_Response([
1555 'success' => false,
1556 'message' => $allowed->get_error_message(),
1557 ], $allowed->get_error_data()['status'] ?? 429);
1558 }
1559
1560 try {
1561 $ai_manager = new \ThinkRank\AI\Manager();
1562 $ai_manager->initialize_client();
1563
1564 $result = $ai_manager->generate_dofollow_link($content, $options);
1565
1566 return new \WP_REST_Response([
1567 'success' => true,
1568 'data' => $result,
1569 'message' => __('Added an authoritative source link', 'thinkrank'),
1570 ]);
1571 } catch (\Exception $e) {
1572 return new \WP_REST_Response([
1573 'success' => false,
1574 'message' => $e->getMessage(),
1575 ], 400);
1576 }
1577 }
1578
1579 /**
1580 * Generate a keyword-rich paragraph to lift keyword density into band.
1581 *
1582 * @param \WP_REST_Request $request Request object.
1583 * @return \WP_REST_Response Response with the HTML fragment under data.html.
1584 * @throws \Exception When generation fails or the AI client is unavailable.
1585 */
1586 public function add_ai_keyword_paragraph(\WP_REST_Request $request): \WP_REST_Response {
1587 $content = $request->get_param('content');
1588 $options = [
1589 'target_keyword' => $request->get_param('target_keyword'),
1590 'content_type' => $request->get_param('content_type'),
1591 'tone' => $request->get_param('tone'),
1592 'word_count' => $request->get_param('word_count'),
1593 'keyword_count' => $request->get_param('keyword_count'),
1594 ];
1595
1596 // Rate limiting: shares the AI generation bucket.
1597 $user_id = get_current_user_id();
1598 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1599 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1600 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1601 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1602 if (is_wp_error($allowed)) {
1603 return new \WP_REST_Response([
1604 'success' => false,
1605 'message' => $allowed->get_error_message(),
1606 ], $allowed->get_error_data()['status'] ?? 429);
1607 }
1608
1609 try {
1610 $ai_manager = new \ThinkRank\AI\Manager();
1611 $ai_manager->initialize_client();
1612
1613 $result = $ai_manager->generate_keyword_paragraph($content, $options);
1614
1615 return new \WP_REST_Response([
1616 'success' => true,
1617 'data' => $result,
1618 'message' => __('Added a keyword-focused paragraph', 'thinkrank'),
1619 ]);
1620 } catch (\Exception $e) {
1621 return new \WP_REST_Response([
1622 'success' => false,
1623 'message' => $e->getMessage(),
1624 ], 400);
1625 }
1626 }
1627
1628 /**
1629 * Enable ThinkRank's Global SEO schema output for a post's post type.
1630 *
1631 * Sets a sensible default schema type (Article for posts, WebPage for pages)
1632 * when none is configured yet, so ThinkRank emits JSON-LD for the post. This
1633 * resolves the "add structured data" suggestion, which the scorer now credits
1634 * when ThinkRank schema is active.
1635 *
1636 * @param \WP_REST_Request $request Request object.
1637 * @return \WP_REST_Response Response describing the enabled schema type.
1638 */
1639 public function enable_schema_for_post(\WP_REST_Request $request): \WP_REST_Response {
1640 $post_id = (int) $request->get_param('post_id');
1641 $post = get_post($post_id);
1642 if (!$post) {
1643 return new \WP_REST_Response([
1644 'success' => false,
1645 'message' => __('Post not found.', 'thinkrank'),
1646 ], 404);
1647 }
1648
1649 $post_type = $post->post_type;
1650 $settings = get_option('thinkrank_global_seo_settings', []);
1651 if (!is_array($settings)) {
1652 $settings = [];
1653 }
1654 if (!isset($settings[$post_type]) || !is_array($settings[$post_type])) {
1655 $settings[$post_type] = [];
1656 }
1657
1658 $already_enabled = !empty($settings[$post_type]['schema_type']);
1659 if (!$already_enabled) {
1660 if ($post_type === 'page') {
1661 $settings[$post_type]['schema_type'] = 'WebPage';
1662 } else {
1663 $settings[$post_type]['schema_type'] = 'Article';
1664 if (empty($settings[$post_type]['article_type'])) {
1665 $settings[$post_type]['article_type'] = 'BlogPosting';
1666 }
1667 }
1668 update_option('thinkrank_global_seo_settings', $settings);
1669 }
1670
1671 $schema_type = $settings[$post_type]['schema_type'];
1672
1673 return new \WP_REST_Response([
1674 'success' => true,
1675 'data' => [
1676 'schema_type' => $schema_type,
1677 'post_type' => $post_type,
1678 'already_enabled' => $already_enabled,
1679 ],
1680 'message' => $already_enabled
1681 ? __('Schema was already enabled for this post type.', 'thinkrank')
1682 /* translators: %s: schema type. */
1683 : sprintf(__('Enabled %s schema for this post type.', 'thinkrank'), $schema_type),
1684 ]);
1685 }
1686
1687 /**
1688 * Test AI connection for specified provider
1689 *
1690 * @param \WP_REST_Request $request Request object containing api_key and provider parameters
1691 * @return \WP_REST_Response Response object with connection test results
1692 * @throws \Exception When API connection test encounters unexpected errors
1693 */
1694 public function test_ai_connection(\WP_REST_Request $request): \WP_REST_Response {
1695 // Rate limiting: per user/IP per route
1696 $user_id = get_current_user_id();
1697 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1698 $bucket_id = 'ai_test|' . ($user_id ?: $ip);
1699 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1700 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1701 if (is_wp_error($allowed)) {
1702 return new \WP_REST_Response([
1703 'success' => false,
1704 'message' => $allowed->get_error_message(),
1705 ], $allowed->get_error_data()['status'] ?? 429);
1706 }
1707
1708 try {
1709 $api_key = $request->get_param('api_key');
1710 $provider = $request->get_param('provider') ?: 'openai';
1711 // The model the caller is asking about. Empty means "whatever is
1712 // saved" — the settings screen sends the model currently on screen
1713 // so an unsaved pick or a hand-typed id is what actually gets
1714 // tested, rather than the last saved one.
1715 $model = trim((string) $request->get_param('model'));
1716
1717 // An unrecognised provider used to fall through to the Gemini arm
1718 // below, so a typo silently tested the wrong provider's key.
1719 if (!in_array($provider, \ThinkRank\Core\Settings::SUPPORTED_AI_PROVIDERS, true)) {
1720 return new \WP_REST_Response([
1721 'success' => false,
1722 /* translators: %s: the unrecognised provider value. */
1723 'message' => sprintf(__('Unknown AI provider: %s', 'thinkrank'), $provider),
1724 ], 400);
1725 }
1726
1727 // The OpenAI-compatible endpoint is tested by URL, not by key: a
1728 // local Ollama or LM Studio server wants no key, so the key checks
1729 // below would refuse to test a perfectly good endpoint (#721).
1730 if ('openai_compatible' === $provider) {
1731 $base_url = trim((string) $request->get_param('base_url'));
1732 if ('' === $base_url) {
1733 $base_url = (string) \ThinkRank\Core\Settings::instance()->get('openai_compatible_base_url', '');
1734 }
1735
1736 if (empty($api_key)) {
1737 $api_key = (string) \ThinkRank\Core\Settings::instance()->get('openai_compatible_api_key', '');
1738 }
1739
1740 if ('' === $model) {
1741 $model = trim((string) \ThinkRank\Core\Settings::instance()->get('openai_compatible_model', ''));
1742 }
1743
1744 $json_mode = $request->has_param('json_mode')
1745 ? (bool) $request->get_param('json_mode')
1746 : (bool) \ThinkRank\Core\Settings::instance()->get('openai_compatible_json_mode', false);
1747
1748 $result = $this->test_openai_compatible_connection($base_url, $api_key, $model, $json_mode);
1749
1750 return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
1751 }
1752
1753 // If no API key provided in request, try to get from saved settings
1754 if (empty($api_key)) {
1755 $settings = \ThinkRank\Core\Settings::instance();
1756 if ($provider === 'openai') {
1757 $api_key = (string) $settings->get('openai_api_key', '');
1758 } elseif ($provider === 'claude') {
1759 $api_key = (string) $settings->get('claude_api_key', '');
1760 } elseif ($provider === 'openrouter') {
1761 $api_key = (string) $settings->get('openrouter_api_key', '');
1762 } else {
1763 $api_key = (string) $settings->get('gemini_api_key', '');
1764 }
1765
1766 if (empty($api_key)) {
1767 return new \WP_REST_Response([
1768 'success' => false,
1769 'message' => __('No API key provided or saved for the selected provider.', 'thinkrank'),
1770 ], 400);
1771 }
1772 }
1773
1774 // Test the connection with a simple API call
1775 if ($provider === 'openai') {
1776 $result = $this->test_openai_connection($api_key, $model);
1777 } elseif ($provider === 'claude') {
1778 $result = $this->test_claude_connection($api_key, $model);
1779 } elseif ($provider === 'openrouter') {
1780 $result = $this->test_openrouter_connection($api_key, $model);
1781 } else {
1782 $result = $this->test_gemini_connection($api_key, $model);
1783 }
1784
1785 return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
1786 } catch (\Exception $e) {
1787 return new \WP_REST_Response([
1788 'success' => false,
1789 'message' => $e->getMessage(),
1790 ], 500);
1791 }
1792 }
1793
1794 /**
1795 * Test an OpenAI-compatible endpoint with a real (tiny) completion.
1796 *
1797 * Deliberately not a GET /models probe: a server can list models and still
1798 * fail to complete (wrong model id, model not pulled, gateway that only
1799 * proxies /models). The one-token chat completion answers the question the
1800 * user is actually asking — "can ThinkRank generate with this?" — and its
1801 * reply plus latency is what the settings screen shows (#721).
1802 *
1803 * @since 2.8.0
1804 *
1805 * @param string $base_url Base URL as typed (validated here).
1806 * @param string $api_key Optional API key.
1807 * @param string $model Model id to complete with.
1808 * @param bool $json_mode Also check the server accepts response_format json_object.
1809 * @return array Test result.
1810 */
1811 private function test_openai_compatible_connection(string $base_url, string $api_key, string $model, bool $json_mode = false): array {
1812 $validated = \ThinkRank\AI\Endpoint_URL_Validator::validate($base_url);
1813 if (is_wp_error($validated)) {
1814 return [
1815 'success' => false,
1816 'message' => $validated->get_error_message(),
1817 ];
1818 }
1819
1820 if ('' === trim($model)) {
1821 return [
1822 'success' => false,
1823 'message' => __('Enter the model id your endpoint should use, for example llama3.1 or gpt-4o.', 'thinkrank'),
1824 ];
1825 }
1826
1827 $headers = ['Content-Type' => 'application/json'];
1828 if ('' !== $api_key) {
1829 $headers['Authorization'] = 'Bearer ' . $api_key;
1830 $headers['api-key'] = $api_key;
1831 }
1832
1833 // A "Reply with OK" answer is tiny; anything approaching this is a
1834 // server misbehaving, and the guard caps it before it is buffered.
1835 $max_bytes = 131072;
1836
1837 $started = microtime(true);
1838
1839 $response = \ThinkRank\AI\Endpoint_URL_Validator::guarded_request(\ThinkRank\AI\Endpoint_URL_Validator::route($validated, 'chat/completions'), [
1840 'method' => 'POST',
1841 // Long enough for a cold local model to load its weights, short
1842 // enough that a wrong URL does not hang the settings screen.
1843 'timeout' => 30,
1844 'headers' => $headers,
1845 'limit_response_size' => $max_bytes,
1846 'body' => wp_json_encode([
1847 'model' => trim($model),
1848 'messages' => [['role' => 'user', 'content' => 'Reply with OK']],
1849 // Not 16: a local reasoning model (deepseek-r1, a qwen3
1850 // thinking build) spends its first tokens on hidden reasoning
1851 // and returns empty content if the budget runs out there, which
1852 // would report a working endpoint as broken.
1853 'max_tokens' => 128,
1854 ]),
1855 ]);
1856
1857 $latency_ms = (int) round((microtime(true) - $started) * 1000);
1858
1859 if (is_wp_error($response)) {
1860 return [
1861 'success' => false,
1862 /* translators: %s: transport error, e.g. "cURL error 7: Connection refused". */
1863 'message' => sprintf(__('Could not reach the endpoint: %s', 'thinkrank'), $response->get_error_message()),
1864 ];
1865 }
1866
1867 $status = (int) wp_remote_retrieve_response_code($response);
1868 $raw_body = wp_remote_retrieve_body($response);
1869
1870 // Redirects are never followed (the key would go wherever the endpoint
1871 // points). Say so, rather than letting the empty 3xx body read as a
1872 // wrong model id: an http-to-https upgrade is the usual cause.
1873 if ($status >= 300 && $status < 400) {
1874 $location = (string) wp_remote_retrieve_header($response, 'location');
1875
1876 return [
1877 'success' => false,
1878 'status' => $status,
1879 'message' => '' !== $location
1880 ? sprintf(
1881 /* translators: 1: HTTP status code, 2: the URL the endpoint redirected to. */
1882 __('The endpoint redirected (%1$d) to %2$s. Redirects are refused so your API key cannot follow them. Enter the final URL instead, for example https:// in place of http://.', 'thinkrank'),
1883 $status,
1884 esc_url_raw($location)
1885 )
1886 : sprintf(
1887 /* translators: %d: HTTP status code. */
1888 __('The endpoint redirected (%d). Redirects are refused so your API key cannot follow them. Enter the final URL instead, for example https:// in place of http://.', 'thinkrank'),
1889 $status
1890 ),
1891 ];
1892 }
1893
1894 // A body that reached the cap was cut mid-JSON. Report that, not a
1895 // missing completion: the model id was never the problem.
1896 if (strlen($raw_body) >= $max_bytes) {
1897 return [
1898 'success' => false,
1899 'status' => $status,
1900 'message' => __('The endpoint sent more than ThinkRank will read for a connection test (128 KB). It is misconfigured or is not answering with a chat completion.', 'thinkrank'),
1901 ];
1902 }
1903
1904 $body = json_decode($raw_body, true);
1905
1906 if ($status >= 400) {
1907 $error = '';
1908 if (is_array($body)) {
1909 $error = (string) ($body['error']['message'] ?? ($body['error'] ?? ($body['message'] ?? '')));
1910 }
1911 if ('' === $error) {
1912 $error = wp_remote_retrieve_response_message($response);
1913 }
1914
1915 return [
1916 'success' => false,
1917 'status' => $status,
1918 /* translators: 1: HTTP status code, 2: error message from the server. */
1919 'message' => sprintf(__('The endpoint answered %1$d: %2$s', 'thinkrank'), $status, $error),
1920 ];
1921 }
1922
1923 $reply = '';
1924 $reasoning_only = false;
1925 if (is_array($body)) {
1926 $message = is_array($body['choices'][0]['message'] ?? null) ? $body['choices'][0]['message'] : [];
1927 $reply = trim((string) ($message['content'] ?? ''));
1928
1929 // Ollama and vLLM expose a thinking model's hidden reasoning
1930 // separately. Reasoning with no content still proves the endpoint
1931 // and the model work — it means the model thinks before answering,
1932 // which is worth saying out loud because it makes every generation
1933 // slower.
1934 if ('' === $reply) {
1935 $reasoning = trim((string) ($message['reasoning'] ?? ($message['reasoning_content'] ?? '')));
1936 if ('' !== $reasoning) {
1937 $reply = $reasoning;
1938 $reasoning_only = true;
1939 }
1940 }
1941 }
1942
1943 if ('' === $reply) {
1944 return [
1945 'success' => false,
1946 'status' => $status,
1947 'message' => __('The endpoint replied, but with no completion text. Check that the model id is one this server serves.', 'thinkrank'),
1948 ];
1949 }
1950
1951 $result = [
1952 'success' => true,
1953 'model' => trim($model),
1954 'model_available' => true,
1955 'latency_ms' => $latency_ms,
1956 'reply' => mb_substr($reply, 0, 200),
1957 'reasoning_only' => $reasoning_only,
1958 'message' => $reasoning_only
1959 ? sprintf(
1960 /* translators: 1: model id, 2: latency in milliseconds. */
1961 __('Connected: "%1$s" answered in %2$d ms. It is a reasoning model: it thinks before replying, so generation will be slower and may need a higher timeout.', 'thinkrank'),
1962 trim($model),
1963 $latency_ms
1964 )
1965 : sprintf(
1966 /* translators: 1: model id, 2: latency in milliseconds, 3: the model's reply. */
1967 __('Connected: "%1$s" replied in %2$d ms: %3$s', 'thinkrank'),
1968 trim($model),
1969 $latency_ms,
1970 mb_substr($reply, 0, 80)
1971 ),
1972 ];
1973
1974 return $json_mode
1975 ? $this->probe_openai_compatible_json_mode($validated, $headers, trim($model), $result)
1976 : $result;
1977 }
1978
1979 /**
1980 * Check that an endpoint takes response_format json_object.
1981 *
1982 * Runs only after the plain completion worked, so a failure here can mean
1983 * one thing: the endpoint works, but not with "Force valid JSON answers"
1984 * on. Generation still works then, because OpenAI_Client falls back to a
1985 * plain request, but every JSON call pays a failed round trip first. The
1986 * connection stays a success; the result carries json_mode_supported so
1987 * the screen can warn instead of reporting a broken endpoint.
1988 *
1989 * @since 2.8.0
1990 *
1991 * @param string $base_url Validated base URL.
1992 * @param array $headers Request headers, key included.
1993 * @param string $model Model id.
1994 * @param array $result Successful connection result to extend.
1995 * @return array The result, with json_mode_supported and, when false, a warning message.
1996 */
1997 private function probe_openai_compatible_json_mode(string $base_url, array $headers, string $model, array $result): array {
1998 $response = \ThinkRank\AI\Endpoint_URL_Validator::guarded_request(\ThinkRank\AI\Endpoint_URL_Validator::route($base_url, 'chat/completions'), [
1999 'method' => 'POST',
2000 'timeout' => 30,
2001 'headers' => $headers,
2002 'limit_response_size' => 131072,
2003 'body' => wp_json_encode([
2004 'model' => $model,
2005 // OpenAI refuses json_object unless the messages mention JSON.
2006 'messages' => [['role' => 'user', 'content' => 'Reply with the JSON object {"ok": true}']],
2007 'max_tokens' => 128,
2008 'response_format' => ['type' => 'json_object'],
2009 ]),
2010 ]);
2011
2012 // A timeout or dropped connection says nothing about JSON mode. Leave
2013 // the result alone rather than warn about a field that was never judged.
2014 if (is_wp_error($response)) {
2015 return $result;
2016 }
2017
2018 $status = (int) wp_remote_retrieve_response_code($response);
2019 if ($status < 400) {
2020 $result['json_mode_supported'] = true;
2021 return $result;
2022 }
2023
2024 $body = json_decode((string) wp_remote_retrieve_body($response), true);
2025 $error = '';
2026 if (is_array($body)) {
2027 // OpenAI and vLLM nest the text under error.message; Ollama sends a bare error string.
2028 $error = is_string($body['error'] ?? null)
2029 ? $body['error']
2030 : (string) ($body['error']['message'] ?? ($body['message'] ?? ''));
2031 }
2032 if ('' === $error) {
2033 $error = (string) wp_remote_retrieve_response_message($response);
2034 }
2035
2036 $result['json_mode_supported'] = false;
2037 $result['message'] = sprintf(
2038 /* translators: 1: model id, 2: HTTP status code, 3: error message from the server. */
2039 __('Connected to "%1$s", but the endpoint rejected JSON mode (%2$d: %3$s). Turn off "Force valid JSON answers": generation still works, but each request is sent twice.', 'thinkrank'),
2040 $model,
2041 $status,
2042 $error
2043 );
2044
2045 return $result;
2046 }
2047
2048 /**
2049 * List the models an OpenAI-compatible endpoint serves.
2050 *
2051 * @since 2.8.0
2052 *
2053 * @param \WP_REST_Request $request Request object.
2054 * @return \WP_REST_Response Response object.
2055 */
2056 public function list_endpoint_models(\WP_REST_Request $request): \WP_REST_Response {
2057 $settings = \ThinkRank\Core\Settings::instance();
2058
2059 $base_url = trim((string) $request->get_param('base_url'));
2060 if ('' === $base_url) {
2061 $base_url = (string) $settings->get('openai_compatible_base_url', '');
2062 }
2063
2064 $validated = \ThinkRank\AI\Endpoint_URL_Validator::validate($base_url);
2065 if (is_wp_error($validated)) {
2066 return new \WP_REST_Response([
2067 'success' => false,
2068 'message' => $validated->get_error_message(),
2069 ], 400);
2070 }
2071
2072 $api_key = trim((string) $request->get_param('api_key'));
2073 if ('' === $api_key || false !== strpos($api_key, '••••••••')) {
2074 $api_key = (string) $settings->get('openai_compatible_api_key', '');
2075 }
2076
2077 $headers = ['Content-Type' => 'application/json'];
2078 if ('' !== $api_key) {
2079 $headers['Authorization'] = 'Bearer ' . $api_key;
2080 $headers['api-key'] = $api_key;
2081 }
2082
2083 $response = \ThinkRank\AI\Endpoint_URL_Validator::guarded_request(\ThinkRank\AI\Endpoint_URL_Validator::route($validated, 'models'), [
2084 'method' => 'GET',
2085 'timeout' => 15,
2086 'headers' => $headers,
2087 // A hostile or misconfigured endpoint can answer with an unbounded
2088 // body; buffering it whole would spend the worker's memory on a
2089 // list we cap at MAX_ENDPOINT_MODELS anyway.
2090 'limit_response_size' => self::MAX_MODELS_RESPONSE_BYTES,
2091 ]);
2092
2093 if (is_wp_error($response)) {
2094 return new \WP_REST_Response([
2095 'success' => false,
2096 /* translators: %s: transport error. */
2097 'message' => sprintf(__('Could not reach the endpoint: %s', 'thinkrank'), $response->get_error_message()),
2098 ], 400);
2099 }
2100
2101 $status = (int) wp_remote_retrieve_response_code($response);
2102 $body = json_decode(wp_remote_retrieve_body($response), true);
2103
2104 if ($status >= 400 || !is_array($body)) {
2105 return new \WP_REST_Response([
2106 'success' => false,
2107 /* translators: %d: HTTP status code. */
2108 'message' => sprintf(__('This endpoint does not list its models (HTTP %d). Type the model id by hand instead.', 'thinkrank'), $status),
2109 ], 400);
2110 }
2111
2112 // OpenAI's shape is {data: [{id: …}]}; some gateways answer a bare list.
2113 $entries = isset($body['data']) && is_array($body['data']) ? $body['data'] : $body;
2114 $models = [];
2115 $truncated = false;
2116 foreach ($entries as $entry) {
2117 if (count($models) >= self::MAX_ENDPOINT_MODELS) {
2118 // A gateway fronting a public catalogue can list thousands of
2119 // models. Sanitising and sorting all of them is work nobody
2120 // asked for — the field is a suggestion list, not a registry.
2121 $truncated = true;
2122 break;
2123 }
2124
2125 if (is_array($entry) && !empty($entry['id'])) {
2126 $models[] = sanitize_text_field((string) $entry['id']);
2127 } elseif (is_string($entry) && '' !== $entry) {
2128 $models[] = sanitize_text_field($entry);
2129 }
2130 }
2131
2132 $models = array_values(array_unique($models));
2133 sort($models);
2134
2135 if (empty($models)) {
2136 return new \WP_REST_Response([
2137 'success' => false,
2138 'message' => __('The endpoint answered, but listed no models. Type the model id by hand instead.', 'thinkrank'),
2139 ], 400);
2140 }
2141
2142 return new \WP_REST_Response([
2143 'success' => true,
2144 'models' => $models,
2145 'truncated' => $truncated,
2146 ]);
2147 }
2148
2149 /**
2150 * Test OpenAI API connection
2151 *
2152 * The models endpoint doubles as the model check: it answers with every id
2153 * this key may call, so an unknown or unentitled model is caught here
2154 * instead of at the first real generation.
2155 *
2156 * @param string $api_key API key to test
2157 * @param string $model Model id to verify, or '' to use the saved one
2158 * @return array Test result
2159 */
2160 private function test_openai_connection(string $api_key, string $model = ''): array {
2161 $model = $model !== ''
2162 ? $model
2163 : (string) \ThinkRank\Core\Settings::instance()->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL);
2164
2165 $url = 'https://api.openai.com/v1/models';
2166
2167 $response = wp_remote_get($url, [
2168 'headers' => [
2169 'Authorization' => 'Bearer ' . $api_key,
2170 'Content-Type' => 'application/json',
2171 ],
2172 'timeout' => 10,
2173 ]);
2174
2175 if (is_wp_error($response)) {
2176 return [
2177 'success' => false,
2178 'message' => __('Failed to connect to OpenAI API: ', 'thinkrank') . $response->get_error_message(),
2179 ];
2180 }
2181
2182 $status_code = wp_remote_retrieve_response_code($response);
2183 $body = wp_remote_retrieve_body($response);
2184
2185 if ($status_code === 200) {
2186 $data = json_decode($body, true);
2187 if (isset($data['data']) && is_array($data['data'])) {
2188 $ids = array_column($data['data'], 'id');
2189
2190 if ($model !== '' && !in_array($model, $ids, true)) {
2191 return [
2192 'success' => false,
2193 'model' => $model,
2194 'model_available' => false,
2195 /* translators: %s: the model id that was tested. */
2196 'message' => sprintf(__('API key works, but the model "%s" is not available to this account.', 'thinkrank'), $model),
2197 ];
2198 }
2199
2200 return [
2201 'success' => true,
2202 'model' => $model,
2203 'model_available' => $model !== '',
2204 'message' => $model !== ''
2205 /* translators: %s: the model id that was tested. */
2206 ? sprintf(__('OpenAI API connection successful. Model "%s" is available.', 'thinkrank'), $model)
2207 : __('OpenAI API connection successful!', 'thinkrank'),
2208 'models_count' => count($data['data']),
2209 ];
2210 }
2211 }
2212
2213 // Handle error response
2214 $error_data = json_decode($body, true);
2215 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
2216
2217 return [
2218 'success' => false,
2219 'message' => __('OpenAI API Error: ', 'thinkrank') . $error_message,
2220 ];
2221 }
2222
2223 /**
2224 * Test OpenRouter API connection
2225 *
2226 * @param string $api_key API key to test
2227 * @param string $model Model id to verify, or '' to use the saved one
2228 * @return array Test result
2229 */
2230 private function test_openrouter_connection(string $api_key, string $model = ''): array {
2231 $model = $model !== ''
2232 ? $model
2233 : (string) \ThinkRank\Core\Settings::instance()->get('openrouter_model', \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL);
2234
2235 // Validate the key format first (OpenRouter keys start with "sk-or-").
2236 if (!str_starts_with($api_key, 'sk-or-')) {
2237 return [
2238 'success' => false,
2239 'message' => __('Invalid OpenRouter API key format. Should start with "sk-or-"', 'thinkrank'),
2240 ];
2241 }
2242
2243 // The key endpoint validates the credential and returns its metadata.
2244 $url = 'https://openrouter.ai/api/v1/key';
2245
2246 $response = wp_remote_get($url, [
2247 'headers' => [
2248 'Authorization' => 'Bearer ' . $api_key,
2249 'Content-Type' => 'application/json',
2250 'HTTP-Referer' => home_url('/'),
2251 'X-Title' => 'ThinkRank',
2252 ],
2253 'timeout' => 10,
2254 ]);
2255
2256 if (is_wp_error($response)) {
2257 return [
2258 'success' => false,
2259 'message' => __('Failed to connect to OpenRouter API: ', 'thinkrank') . $response->get_error_message(),
2260 ];
2261 }
2262
2263 $status_code = wp_remote_retrieve_response_code($response);
2264 $body = wp_remote_retrieve_body($response);
2265
2266 if ($status_code === 200) {
2267 $data = json_decode($body, true);
2268 if (isset($data['data']) && is_array($data['data'])) {
2269 // The key is good; the catalogue is a separate document, so
2270 // the model needs its own lookup.
2271 if ($model !== '') {
2272 $model_check = $this->check_openrouter_model($api_key, $model);
2273 if ($model_check !== null) {
2274 return $model_check;
2275 }
2276 }
2277
2278 return [
2279 'success' => true,
2280 'model' => $model,
2281 'model_available' => $model !== '',
2282 'message' => $model !== ''
2283 /* translators: %s: the model id that was tested. */
2284 ? sprintf(__('OpenRouter API connection successful. Model "%s" is available.', 'thinkrank'), $model)
2285 : __('OpenRouter API connection successful!', 'thinkrank'),
2286 ];
2287 }
2288 }
2289
2290 // Handle error response
2291 $error_data = json_decode($body, true);
2292 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
2293
2294 return [
2295 'success' => false,
2296 'message' => __('OpenRouter API Error: ', 'thinkrank') . $error_message,
2297 ];
2298 }
2299
2300 /**
2301 * Verify a model id against OpenRouter's public catalogue.
2302 *
2303 * @param string $api_key API key to authenticate the lookup
2304 * @param string $model Model id to look for
2305 * @return array|null Failure payload when the model is unknown, null when it
2306 * is available or when the catalogue could not be read —
2307 * a listing hiccup must not fail an otherwise good key.
2308 */
2309 private function check_openrouter_model(string $api_key, string $model): ?array {
2310 $response = wp_remote_get('https://openrouter.ai/api/v1/models', [
2311 'headers' => [
2312 'Authorization' => 'Bearer ' . $api_key,
2313 'Content-Type' => 'application/json',
2314 'HTTP-Referer' => home_url('/'),
2315 'X-Title' => 'ThinkRank',
2316 ],
2317 'timeout' => 10,
2318 ]);
2319
2320 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
2321 return null;
2322 }
2323
2324 $data = json_decode(wp_remote_retrieve_body($response), true);
2325 if (!isset($data['data']) || !is_array($data['data'])) {
2326 return null;
2327 }
2328
2329 $ids = array_column($data['data'], 'id');
2330 if (in_array($model, $ids, true)) {
2331 return null;
2332 }
2333
2334 return [
2335 'success' => false,
2336 'model' => $model,
2337 'model_available' => false,
2338 /* translators: %s: the model id that was tested. */
2339 'message' => sprintf(__('API key works, but "%s" is not a model OpenRouter offers.', 'thinkrank'), $model),
2340 ];
2341 }
2342
2343 /**
2344 * Test Claude API connection
2345 *
2346 * @param string $api_key API key to test
2347 * @param string $model Model id to verify, or '' to use the saved one
2348 * @return array Test result
2349 */
2350 private function test_claude_connection(string $api_key, string $model = ''): array {
2351 // First validate the key format
2352 if (!str_starts_with($api_key, 'sk-ant-')) {
2353 return [
2354 'success' => false,
2355 'message' => __('Invalid Claude API key format. Should start with "sk-ant-"', 'thinkrank'),
2356 ];
2357 }
2358
2359 // Test with a simple API call
2360 $url = 'https://api.anthropic.com/v1/messages';
2361
2362 // A model sent with the request is tested verbatim: normalizing it would
2363 // quietly swap a typo for a working id and report success for a model
2364 // the user never asked for. Only the saved fallback is self-healed, as
2365 // that is the path where a retired id from an older release shows up.
2366 if ($model !== '') {
2367 $claude_model = $model;
2368 } else {
2369 $claude_model = \ThinkRank\Core\Settings::instance()->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL);
2370 $claude_model = \ThinkRank\AI\Claude_Client::normalize_model($claude_model);
2371 }
2372
2373 $body = [
2374 'model' => $claude_model,
2375 'max_tokens' => 10,
2376 'messages' => [
2377 [
2378 'role' => 'user',
2379 'content' => 'Hello'
2380 ]
2381 ]
2382 ];
2383
2384 $response = wp_remote_post($url, [
2385 'headers' => [
2386 'x-api-key' => $api_key,
2387 'Content-Type' => 'application/json',
2388 'anthropic-version' => '2023-06-01',
2389 ],
2390 'body' => wp_json_encode($body),
2391 'timeout' => 10,
2392 ]);
2393
2394 if (is_wp_error($response)) {
2395 return [
2396 'success' => false,
2397 'message' => __('Failed to connect to Claude API: ', 'thinkrank') . $response->get_error_message(),
2398 ];
2399 }
2400
2401 $status_code = wp_remote_retrieve_response_code($response);
2402 $response_body = wp_remote_retrieve_body($response);
2403
2404 if ($status_code === 200) {
2405 return [
2406 'success' => true,
2407 'model' => $claude_model,
2408 'model_available' => true,
2409 /* translators: %s: the model id that was tested. */
2410 'message' => sprintf(__('Claude API connection successful. Model "%s" is available.', 'thinkrank'), $claude_model),
2411 ];
2412 } else {
2413 $error_data = json_decode($response_body, true);
2414 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
2415
2416 // 404 on /v1/messages means the key authenticated but the model id
2417 // does not exist — say so, instead of blaming the key.
2418 if ($status_code === 404) {
2419 return [
2420 'success' => false,
2421 'model' => $claude_model,
2422 'model_available' => false,
2423 /* translators: %s: the model id that was tested. */
2424 'message' => sprintf(__('API key works, but the model "%s" was not found.', 'thinkrank'), $claude_model),
2425 ];
2426 }
2427
2428 return [
2429 'success' => false,
2430 'model' => $claude_model,
2431 /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */
2432 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
2433 ];
2434 }
2435 }
2436
2437 /**
2438 * Test Gemini API connection
2439 *
2440 * @param string $api_key API key to test
2441 * @param string $model Model id to verify, or '' to use the saved one
2442 * @return array Test result
2443 */
2444 private function test_gemini_connection(string $api_key, string $model = ''): array {
2445 // Test with a simple API call
2446 $gemini_model = $model !== ''
2447 ? $model
2448 : (string) \ThinkRank\Core\Settings::instance()->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL);
2449
2450 // The model is a path segment, and ids may arrive with the "models/"
2451 // prefix Google's own docs use.
2452 $gemini_model = ltrim($gemini_model, '/');
2453 $gemini_model = preg_replace('#^models/#', '', $gemini_model);
2454
2455 $url = 'https://generativelanguage.googleapis.com/v1beta/models/'
2456 . rawurlencode($gemini_model)
2457 . ':generateContent?key=' . rawurlencode($api_key);
2458
2459 $body = [
2460 'contents' => [
2461 [
2462 'parts' => [
2463 ['text' => 'Hello']
2464 ]
2465 ]
2466 ],
2467 'generationConfig' => [
2468 'maxOutputTokens' => 10,
2469 'temperature' => 0.1,
2470 ]
2471 ];
2472
2473 $response = wp_remote_post($url, [
2474 'headers' => [
2475 'Content-Type' => 'application/json',
2476 ],
2477 'body' => wp_json_encode($body),
2478 'timeout' => 10,
2479 ]);
2480
2481 if (is_wp_error($response)) {
2482 return [
2483 'success' => false,
2484 'message' => __('Failed to connect to Gemini API: ', 'thinkrank') . $response->get_error_message(),
2485 ];
2486 }
2487
2488 $status_code = wp_remote_retrieve_response_code($response);
2489 $response_body = wp_remote_retrieve_body($response);
2490
2491 if ($status_code === 200) {
2492 return [
2493 'success' => true,
2494 'model' => $gemini_model,
2495 'model_available' => true,
2496 /* translators: %s: the model id that was tested. */
2497 'message' => sprintf(__('Gemini API connection successful. Model "%s" is available.', 'thinkrank'), $gemini_model),
2498 ];
2499 } else {
2500 $error_data = json_decode($response_body, true);
2501 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
2502
2503 // Gemini answers 404 for a model id it does not serve; the key
2504 // itself authenticated fine, so name the real problem.
2505 if ($status_code === 404) {
2506 return [
2507 'success' => false,
2508 'model' => $gemini_model,
2509 'model_available' => false,
2510 /* translators: %s: the model id that was tested. */
2511 'message' => sprintf(__('API key works, but the model "%s" was not found.', 'thinkrank'), $gemini_model),
2512 ];
2513 }
2514
2515 return [
2516 'success' => false,
2517 'model' => $gemini_model,
2518 /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */
2519 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
2520 ];
2521 }
2522 }
2523
2524 /**
2525 * Get AI providers
2526 *
2527 * @param \WP_REST_Request $request Request object
2528 * @return \WP_REST_Response Response object
2529 */
2530 public function get_ai_providers(\WP_REST_Request $request): \WP_REST_Response {
2531 $ai_manager = new \ThinkRank\AI\Manager();
2532 $providers = $ai_manager->get_available_providers();
2533
2534 return new \WP_REST_Response($providers);
2535 }
2536
2537 /**
2538 * Get AI status
2539 *
2540 * @param \WP_REST_Request $request Request object
2541 * @return \WP_REST_Response Response object
2542 */
2543 public function get_ai_status(\WP_REST_Request $request): \WP_REST_Response {
2544 $ai_manager = new \ThinkRank\AI\Manager();
2545 $status = $ai_manager->get_provider_status();
2546
2547 // The spend ceiling and kill switch ride on the status the AI screen
2548 // already polls, rather than a route of their own: a counter the user
2549 // has to refresh separately to trust is a counter they will not trust
2550 // (#448).
2551 $status['budget'] = \ThinkRank\AI\Spend_Guard::status();
2552
2553 return new \WP_REST_Response($status);
2554 }
2555
2556 /**
2557 * Analyze content for SEO optimization
2558 *
2559 * @param \WP_REST_Request $request Request object containing content, metadata, and optional post_id
2560 * @return \WP_REST_Response Response object with analysis results or error message
2561 * @throws \Exception When AI analysis fails or AI client initialization fails
2562 */
2563 public function analyze_content(\WP_REST_Request $request): \WP_REST_Response {
2564 $content = $request->get_param('content');
2565 $metadata = $request->get_param('metadata') ?: [];
2566 $post_id = $request->get_param('post_id');
2567
2568 // Rate limiting: per user/IP per route
2569 $user_id = get_current_user_id();
2570 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
2571 $bucket_id = 'ai_analyze|' . ($user_id ?: $ip);
2572 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
2573 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
2574 if (is_wp_error($allowed)) {
2575 return new \WP_REST_Response([
2576 'success' => false,
2577 'message' => $allowed->get_error_message(),
2578 ], $allowed->get_error_data()['status'] ?? 429);
2579 }
2580
2581 try {
2582 // Get AI manager instance
2583 $ai_manager = new \ThinkRank\AI\Manager();
2584 $ai_manager->initialize_client();
2585
2586 // Perform content analysis
2587 $analysis = $ai_manager->analyze_content($content, $metadata);
2588
2589 return new \WP_REST_Response([
2590 'success' => true,
2591 'data' => $analysis,
2592 'message' => __('Content analyzed successfully', 'thinkrank'),
2593 ]);
2594 } catch (\Exception $e) {
2595 return new \WP_REST_Response([
2596 'success' => false,
2597 'message' => $e->getMessage(),
2598 ], 400);
2599 }
2600 }
2601
2602 /**
2603 * Sanitize metadata object for API endpoints
2604 *
2605 * @param mixed $metadata Metadata to sanitize
2606 * @return array Sanitized metadata array
2607 */
2608 public function sanitize_metadata_object($metadata): array {
2609 if (!is_array($metadata)) {
2610 return [];
2611 }
2612
2613 $sanitized = [];
2614 foreach ($metadata as $key => $value) {
2615 $sanitized_key = sanitize_key($key);
2616
2617 if (is_string($value)) {
2618 $sanitized[$sanitized_key] = sanitize_text_field($value);
2619 } elseif (is_array($value)) {
2620 // Recursively sanitize nested arrays
2621 $sanitized[$sanitized_key] = array_map('sanitize_text_field', $value);
2622 } elseif (is_numeric($value)) {
2623 $sanitized[$sanitized_key] = (float) $value;
2624 } elseif (is_bool($value)) {
2625 $sanitized[$sanitized_key] = (bool) $value;
2626 }
2627 // Skip other data types for security
2628 }
2629
2630 return $sanitized;
2631 }
2632
2633 /**
2634 * Register endpoint classes
2635 *
2636 * @return void
2637 */
2638 public function register_endpoint_classes(): void {
2639 // Register endpoint classes that exist
2640 try {
2641 $site_identity_endpoint = new Site_Identity_Endpoint();
2642 $site_identity_endpoint->register_routes();
2643 } catch (\Exception $e) {
2644 // Failed to register Site Identity endpoint
2645 }
2646
2647 try {
2648 $ai_insights_endpoint = new Ai_Insights_Endpoint();
2649 $ai_insights_endpoint->register_routes();
2650 } catch (\Exception $e) {
2651 // Failed to register AI Insights endpoint
2652 }
2653
2654 try {
2655 $performance_endpoint = new Performance_Endpoint();
2656 $performance_endpoint->register_routes();
2657 } catch (\Exception $e) {
2658 // Failed to register Performance endpoint
2659 }
2660
2661 try {
2662 $schema_endpoint = new Schema_Endpoint();
2663 $schema_endpoint->register_routes();
2664 } catch (\Exception $e) {
2665 // Failed to register Schema endpoint
2666 }
2667
2668 try {
2669 $settings_endpoint = new Settings_Management_Endpoint();
2670 $settings_endpoint->register_routes();
2671 } catch (\Exception $e) {
2672 // Failed to register Settings Management endpoint
2673 }
2674
2675 try {
2676 $integrations_endpoint = new Integrations_Endpoint();
2677 $integrations_endpoint->register_routes();
2678 } catch (\Exception $e) {
2679 // Failed to register Integrations endpoint
2680 }
2681
2682 try {
2683 $social_platforms_endpoint = new Social_Platforms_Endpoint();
2684 $social_platforms_endpoint->register_routes();
2685 } catch (\Exception $e) {
2686 // Failed to register Social Platforms endpoint
2687 }
2688
2689 try {
2690 $content_brief_endpoint = new Content_Brief_Endpoint();
2691 $content_brief_endpoint->register_routes();
2692 } catch (\Exception $e) {
2693 // Failed to register Content Brief endpoint
2694 }
2695
2696 try {
2697 $social_media_endpoint = new Social_Media_Endpoint();
2698 $social_media_endpoint->register_routes();
2699 } catch (\Exception $e) {
2700 // Failed to register Social Media endpoint
2701 }
2702
2703 try {
2704 $sitemap_endpoint = new Sitemap_Endpoint();
2705 $sitemap_endpoint->register_routes();
2706 } catch (\Exception $e) {
2707 // Failed to register Sitemap endpoint
2708 }
2709
2710 try {
2711 $llms_txt_endpoint = new LLMs_Txt_Endpoint();
2712 $llms_txt_endpoint->register_routes();
2713 } catch (\Exception $e) {
2714 // Failed to register LLMs.txt endpoint
2715 }
2716
2717 try {
2718 $global_seo_endpoint = new Global_SEO_Endpoint();
2719 $global_seo_endpoint->register_routes();
2720 } catch (\Exception $e) {
2721 // Failed to register Global SEO endpoint
2722 }
2723
2724 try {
2725 $content_type_matrix_endpoint = new \ThinkRank\API\Content_Type_Matrix_Endpoint();
2726 $content_type_matrix_endpoint->register_routes();
2727 } catch (\Exception $e) {
2728 // Failed to register Content Type Matrix endpoint
2729 }
2730
2731 try {
2732 // Bulk Snippets (#727): lives under global-seo/, so the Role
2733 // Manager's Bulk SEO Optimization capability covers it.
2734 $snippets_endpoint = new \ThinkRank\API\Snippets_Endpoint();
2735 $snippets_endpoint->register_routes();
2736 } catch (\Exception $e) {
2737 // Failed to register Bulk Snippets endpoint
2738 }
2739
2740 try {
2741 // Thin content report (#565): also under global-seo/, so the same
2742 // Bulk SEO Optimization capability covers it.
2743 $thin_content_endpoint = new \ThinkRank\API\Thin_Content_Endpoint();
2744 $thin_content_endpoint->register_routes();
2745 } catch (\Exception $e) {
2746 // Failed to register Thin Content endpoint
2747 }
2748
2749 try {
2750 $image_seo_endpoint = new Image_SEO_Endpoint();
2751 $image_seo_endpoint->register_routes();
2752 } catch (\Exception $e) {
2753 // Failed to register Image SEO endpoint
2754 }
2755
2756 try {
2757 $external_links_endpoint = new External_Links_Endpoint();
2758 $external_links_endpoint->register_routes();
2759 } catch (\Exception $e) {
2760 // Failed to register External Links endpoint
2761 }
2762
2763 // Import_Controller is deliberately NOT gated on enable_migration_tools.
2764 // /import/detect backs the setup wizard's migration step and the record
2765 // count on Settings > Import / Export, and /import/snapshot + /migrate
2766 // run the wizard's actual import — all on a fresh install, where the
2767 // setting is off. Gating them would break onboarding, which is a worse
2768 // bug than the one #583 reports.
2769 try {
2770 $import_controller = new Import_Controller();
2771 $import_controller->register_routes();
2772 } catch (\Exception $e) {
2773 // Failed to register Import endpoint
2774 }
2775
2776 // Export/restore is gated on the setting that gates its admin screen,
2777 // so turning Import / Export off removes its REST surface along with
2778 // its menu item (#583). Nothing in the setup wizard calls these:
2779 // MigrationPluginRow takes startExport/startMigration/cancel from
2780 // useImportWorkflow and never uploadFile. rest_api_init runs per
2781 // request, so a toggle takes effect on the next one — no flush.
2782 if ((bool) \ThinkRank\Core\Settings::instance()->get('enable_import_export', false)) {
2783 try {
2784 $export_controller = new Export_Controller();
2785 $export_controller->register_routes();
2786 } catch (\Exception $e) {
2787 // Failed to register Export endpoint
2788 }
2789 }
2790
2791 try {
2792 $setup_wizard_endpoint = new Setup_Wizard_Endpoint();
2793 $setup_wizard_endpoint->register_routes();
2794 } catch (\Exception $e) {
2795 // Failed to register Setup Wizard endpoint
2796 }
2797
2798 // Simple / Advanced navigation, per user (#730)
2799 try {
2800 (new UI_Mode_Endpoint())->register_routes();
2801 } catch (\Exception $e) {
2802 // Failed to register UI mode endpoint
2803 }
2804 }
2805 }
2806