PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.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 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
← All changes | includes/api/class-site-identity-endpoint.php +152 -19 1.28.0 → 2.9.0 View file →
@@ -19,8 +19,9 @@
19 19
20 20 use ThinkRank\SEO\Site_Identity_Manager;
21 21 use ThinkRank\AI\Manager as AI_Manager;
22 22 use ThinkRank\API\Traits\CSRF_Protection;
23 +use ThinkRank\API\Traits\Context_Authorization;
23 24 use WP_REST_Controller;
24 25 use WP_REST_Request;
25 26 use WP_REST_Response;
26 27 use WP_Error;
@@ -31,8 +32,9 @@
31 32 }
32 33
33 34 // Load CSRF Protection trait
34 35 require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-csrf-protection.php';
36 +require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-context-authorization.php';
35 37
36 38 /**
37 39 * Site Identity API Endpoints Class
38 40 *
@@ -44,8 +46,9 @@
44 46 * @since 1.0.0
45 47 */
46 48 class Site_Identity_Endpoint extends WP_REST_Controller {
47 49 use CSRF_Protection;
50 + use Context_Authorization;
48 51
49 52 /**
50 53 * Site Identity Manager instance
51 54 *
@@ -126,9 +129,10 @@
126 129 [
127 130 [
128 131 'methods' => 'GET',
129 132 'callback' => [$this, 'get_settings'],
130 - 'permission_callback' => [$this, 'check_permissions']
133 + 'permission_callback' => [$this, 'check_permissions'],
134 + 'args' => $this->get_context_route_args()
131 135 ],
132 136 [
133 137 'methods' => 'POST',
134 138 'callback' => [$this, 'update_settings'],
@@ -278,14 +282,19 @@
278 282 *
279 283 * @since 1.0.0
280 284 *
281 285 * @param WP_REST_Request $request Request object
282 - * @return WP_REST_Response Response object
286 + * @return WP_REST_Response|WP_Error Response object, or the context error
283 287 */
284 - public function get_settings(WP_REST_Request $request): WP_REST_Response {
288 + public function get_settings(WP_REST_Request $request) {
285 289 try {
286 - $context_type = $request->get_param('context_type') ?? 'site';
287 - $context_id = $request->get_param('context_id');
290 + // SECURITY: the settings are stored per context, so the object has
291 + // to be authorised before it is read (#385).
292 + $context = $this->resolve_request_context($request);
293 + if (is_wp_error($context)) {
294 + return $context;
295 + }
296 + [$context_type, $context_id] = $context;
288 297
289 298 // Get settings from Site Identity Manager
290 299 $settings = $this->identity_manager->get_settings($context_type, $context_id);
291 300
@@ -321,11 +330,17 @@
321 330 */
322 331 public function update_settings(WP_REST_Request $request) {
323 332 try {
324 333 $settings = $request->get_param('settings');
325 - $context_type = $request->get_param('context_type') ?? 'site';
326 - $context_id = $request->get_param('context_id');
327 334
335 + // SECURITY: this write is keyed by the context, so the object has to
336 + // be authorised before anything is persisted (#385).
337 + $context = $this->resolve_request_context($request);
338 + if (is_wp_error($context)) {
339 + return $context;
340 + }
341 + [$context_type, $context_id] = $context;
342 +
328 343 // Validate settings
329 344 if (empty($settings) || !is_array($settings)) {
330 345 return new WP_Error(
331 346 'invalid_settings',
@@ -354,10 +369,13 @@
354 369
355 370 if (!$update_result) {
356 371 return new WP_Error(
357 372 'update_failed',
358 - 'Failed to update site identity settings',
359 - ['status' => 500]
373 + $this->describe_save_failure('Failed to update site identity settings'),
374 + [
375 + 'status' => 500,
376 + 'failure_code' => $this->identity_manager->get_last_save_error_code()
377 + ]
360 378 );
361 379 }
362 380
363 381 // If this save changed the robots.txt content/toggle and a physical
@@ -365,11 +383,17 @@
365 383 // static file directly (bypassing the robots_txt filter), so without
366 384 // this the file goes stale and /robots.txt shows the old content
367 385 // while the textarea shows the new — regardless of what the frontend
368 386 // believed about the file's existence.
387 + //
388 + // ai_crawler_rules counts as a robots.txt change even though it is
389 + // not the body: the directives it produces are composed into the
390 + // served output at render time, so a physical file left alone here
391 + // would keep serving the previous allow/block set (#657).
369 392 if ($context_type === 'site'
370 393 && (array_key_exists('robots_txt_content', $settings)
371 - || array_key_exists('robots_txt_enabled', $settings))
394 + || array_key_exists('robots_txt_enabled', $settings)
395 + || array_key_exists('ai_crawler_rules', $settings))
372 396 ) {
373 397 $this->identity_manager->sync_robots_txt_file();
374 398 }
375 399
@@ -386,9 +410,9 @@
386 410 ],
387 411 'message' => 'Site identity settings updated successfully'
388 412 ], 200);
389 413
390 - } catch (\Exception $e) {
414 + } catch (\Throwable $e) {
391 415 return new WP_Error(
392 416 'update_failed',
393 417 'Settings update failed: ' . $e->getMessage(),
394 418 ['status' => 500]
@@ -537,8 +561,34 @@
537 561 // file if present, else the effective content) — header-stripped so
538 562 // the auto-generated comment/timestamp never lands in the textarea.
539 563 $robots_data['content'] = $this->identity_manager->get_served_robots_body();
540 564
565 + // Keep `rules` describing that same body. generate_robots_txt()
566 + // returned the rules it generated, which stopped matching `content`
567 + // the moment a stored override or a physical file supplied it.
568 + $robots_data['rules'] = $this->identity_manager->parse_robots_txt_rules($robots_data['content']);
569 +
570 + // How /robots.txt is actually delivered right now, so the screen can
571 + // show the served output next to the editable body and flag a
572 + // physical file in the web root that has drifted from the settings.
573 + $robots_data['effective'] = $this->identity_manager->get_robots_txt_delivery();
574 +
575 + // The per-agent AI crawler surface (#657). The registry ships with
576 + // the response rather than being duplicated in the bundle, so a
577 + // crawler added by the `thinkrank_ai_crawlers` filter appears in
578 + // the UI without a rebuild. Rules are returned normalised, so a
579 + // crawler with nothing stored comes back explicitly allowed rather
580 + // than as an absence the client has to interpret.
581 + $settings = $this->identity_manager->get_settings('site');
582 + $rules = \ThinkRank\SEO\AI_Crawlers::normalize_rules($settings['ai_crawler_rules'] ?? []);
583 +
584 + $robots_data['ai_crawlers'] = \ThinkRank\SEO\AI_Crawlers::for_display();
585 + $robots_data['ai_crawler_rules'] = [];
586 +
587 + foreach ($robots_data['ai_crawlers'] as $agent) {
588 + $robots_data['ai_crawler_rules'][$agent['slug']] = $rules[$agent['slug']] ?? 'allow';
589 + }
590 +
541 591 return new WP_REST_Response([
542 592 'success' => true,
543 593 'data' => $robots_data,
544 594 'message' => 'Robots.txt data retrieved successfully'
@@ -594,8 +644,17 @@
594 644 }
595 645
596 646 $settings = ['robots_txt_enabled' => $enable_management];
597 647
648 + // Per-agent AI crawler rules (#657). Only written when the caller
649 + // sends them: this route is also the plain "re-sync the file" save,
650 + // and defaulting a missing parameter to an empty map there would
651 + // unblock every crawler the site had blocked.
652 + $ai_rules = $request->get_param('ai_crawler_rules');
653 + if (null !== $ai_rules) {
654 + $settings['ai_crawler_rules'] = \ThinkRank\SEO\AI_Crawlers::normalize_rules($ai_rules);
655 + }
656 +
598 657 if ($regenerate) {
599 658 // Generate and validate robots.txt from the rules.
600 659 $robots_data = $this->identity_manager->generate_robots_txt($custom_rules);
601 660
@@ -619,10 +678,13 @@
619 678
620 679 if (!$update_result) {
621 680 return new WP_Error(
622 681 'update_failed',
623 - 'Failed to update robots.txt settings',
624 - ['status' => 500]
682 + $this->describe_save_failure('Failed to update robots.txt settings'),
683 + [
684 + 'status' => 500,
685 + 'failure_code' => $this->identity_manager->get_last_save_error_code()
686 + ]
625 687 );
626 688 }
627 689
628 690 // Effective content = the stored textarea content when set, else the
@@ -645,9 +707,14 @@
645 707 $robots_data = $robots_data ?? [];
646 708 // Return the header-stripped body so the client textarea reflects
647 709 // exactly what it should hold (the header is added only at render).
648 710 $robots_data['content'] = $this->identity_manager->get_served_robots_body();
711 + $robots_data['rules'] = $this->identity_manager->parse_robots_txt_rules($robots_data['content']);
649 712
713 + // Re-read delivery after the write above so the screen reflects the
714 + // file that now exists rather than the state it was in on load.
715 + $robots_data['effective'] = $this->identity_manager->get_robots_txt_delivery();
716 +
650 717 return new WP_REST_Response([
651 718 'success' => true,
652 719 'data' => [
653 720 'robots_data' => $robots_data,
@@ -658,9 +725,9 @@
658 725 ? 'Robots.txt configuration updated and file written successfully'
659 726 : 'Robots.txt configuration updated (file not written: ' . $file_write_result['message'] . ')'
660 727 ], 200);
661 728
662 - } catch (\Exception $e) {
729 + } catch (\Throwable $e) {
663 730 return new WP_Error(
664 731 'update_failed',
665 732 'Robots.txt update failed: ' . $e->getMessage(),
666 733 ['status' => 500]
@@ -859,12 +926,14 @@
859 926 */
860 927 public function validate_identity_settings(WP_REST_Request $request): WP_REST_Response {
861 928 try {
862 929 $settings = $request->get_param('settings');
863 - $tab_context = $request->get_param('tab_context') ?? '';
930 + $tab_context = $request->get_param('tab_context');
864 931
865 - // Validate settings using Site Identity Manager with tab context
866 - $validation = $this->identity_manager->validate_settings($settings ?? [], $tab_context);
932 + // Validate settings using Site Identity Manager with tab context.
933 + // `settings` is registered required, so REST rejects a missing value
934 + // before this point and the old `?? []` fallback was unreachable.
935 + $validation = $this->identity_manager->validate_settings($settings, $tab_context);
867 936
868 937 return new WP_REST_Response([
869 938 'success' => true,
870 939 'data' => $validation,
@@ -923,8 +992,28 @@
923 992 * Argument validation methods
924 993 */
925 994
926 995 /**
996 + * Build a save-failure message that names the actual cause.
997 + *
998 + * The manager knows why the save failed — missing settings table, rejected
999 + * INSERT with the MySQL error attached — and used to write that to the
1000 + * error log and throw it away, leaving the client a fixed string that told
1001 + * nobody anything. Append the reason so the response is diagnosable on its
1002 + * own. Status stays 500: a rejected INSERT is a server-side failure.
1003 + *
1004 + * @since 1.32.1
1005 + *
1006 + * @param string $fallback Message to use when no reason was recorded.
1007 + * @return string Failure message.
1008 + */
1009 + private function describe_save_failure(string $fallback): string {
1010 + $reason = $this->identity_manager->get_last_save_error();
1011 +
1012 + return '' !== $reason ? $fallback . ': ' . $reason : $fallback;
1013 + }
1014 +
1015 + /**
927 1016 * Get arguments for settings endpoints
928 1017 *
929 1018 * @since 1.0.0
930 1019 *
@@ -964,9 +1053,12 @@
964 1053 return [
965 1054 'template_name' => [
966 1055 'required' => false,
967 1056 'type' => 'string',
968 - 'enum' => ['default', 'simple', 'reverse', 'category', 'author'],
1057 + // Must match get_available_title_templates(), which returns
1058 + // 'default' and nothing else. The extra names advertised
1059 + // templates the resolver has never been able to produce.
1060 + 'enum' => ['default'],
969 1061 'default' => 'default',
970 1062 'description' => 'Title template to use'
971 1063 ],
972 1064 'data' => [
@@ -995,9 +1087,11 @@
995 1087 return [
996 1088 'breadcrumb_type' => [
997 1089 'required' => false,
998 1090 'type' => 'string',
999 - 'enum' => ['hierarchical', 'taxonomy', 'path', 'custom'],
1091 + // Must match get_available_breadcrumb_types(), which returns
1092 + // 'hierarchical' and nothing else.
1093 + 'enum' => ['hierarchical'],
1000 1094 'default' => 'hierarchical',
1001 1095 'description' => 'Type of breadcrumb navigation to generate'
1002 1096 ],
1003 1097 'options' => [
@@ -1035,8 +1129,29 @@
1035 1129 'required' => false,
1036 1130 'type' => 'boolean',
1037 1131 'default' => true,
1038 1132 'description' => 'Rebuild content from rules (Generate). When false, only re-sync the physical file to the stored content.'
1133 + ],
1134 + // The handler reads this and the route never declared it, so it
1135 + // arrived as whatever string the client sent. RobotsManagement.js
1136 + // sends it, and "false" is a non-empty string — truthy — so the
1137 + // file was written when the caller had asked it not to be. ("0" is
1138 + // falsy, which is why the failure was asymmetric.) Registering it
1139 + // gets core's boolean coercion (#394).
1140 + 'ai_crawler_rules' => [
1141 + 'required' => false,
1142 + 'type' => 'object',
1143 + 'description' => 'Per-agent AI crawler rules, keyed by crawler slug, each "allow" or "block".',
1144 + 'additionalProperties' => [
1145 + 'type' => 'string',
1146 + 'enum' => ['allow', 'block'],
1147 + ],
1148 + ],
1149 + 'write_to_file' => [
1150 + 'required' => false,
1151 + 'type' => 'boolean',
1152 + 'default' => true,
1153 + 'description' => 'Write the generated content to the physical robots.txt file.'
1039 1154 ]
1040 1155 ];
1041 1156 }
1042 1157
@@ -1052,8 +1167,16 @@
1052 1167 'identity_data' => [
1053 1168 'required' => true,
1054 1169 'type' => 'object',
1055 1170 'description' => 'Site identity data to optimize'
1171 + ],
1172 + // Read by optimize_site_identity(); previously unregistered, so it
1173 + // never appeared in the published schema.
1174 + 'options' => [
1175 + 'required' => false,
1176 + 'type' => 'object',
1177 + 'default' => [],
1178 + 'description' => 'Additional optimization options'
1056 1179 ]
1057 1180 ];
1058 1181 }
1059 1182
@@ -1200,8 +1323,18 @@
1200 1323 'settings' => [
1201 1324 'required' => true,
1202 1325 'type' => 'object',
1203 1326 'description' => 'Settings to validate'
1327 + ],
1328 + // Read by validate_identity_settings() to scope validation to one
1329 + // tab; it was never registered, so it was absent from the published
1330 + // schema and got no type or sanitization.
1331 + 'tab_context' => [
1332 + 'required' => false,
1333 + 'type' => 'string',
1334 + 'default' => '',
1335 + 'sanitize_callback' => 'sanitize_key',
1336 + 'description' => 'Limit validation to a single settings tab'
1204 1337 ]
1205 1338 ];
1206 1339 }
1207 1340