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