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 +143 -19 2.0.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,13 +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 +
541 570 // How /robots.txt is actually delivered right now, so the screen can
542 571 // show the served output next to the editable body and flag a
543 572 // physical file in the web root that has drifted from the settings.
544 573 $robots_data['effective'] = $this->identity_manager->get_robots_txt_delivery();
545 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 +
546 591 return new WP_REST_Response([
547 592 'success' => true,
548 593 'data' => $robots_data,
549 594 'message' => 'Robots.txt data retrieved successfully'
@@ -599,8 +644,17 @@
599 644 }
600 645
601 646 $settings = ['robots_txt_enabled' => $enable_management];
602 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 +
603 657 if ($regenerate) {
604 658 // Generate and validate robots.txt from the rules.
605 659 $robots_data = $this->identity_manager->generate_robots_txt($custom_rules);
606 660
@@ -624,10 +678,13 @@
624 678
625 679 if (!$update_result) {
626 680 return new WP_Error(
627 681 'update_failed',
628 - 'Failed to update robots.txt settings',
629 - ['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 + ]
630 687 );
631 688 }
632 689
633 690 // Effective content = the stored textarea content when set, else the
@@ -650,8 +707,9 @@
650 707 $robots_data = $robots_data ?? [];
651 708 // Return the header-stripped body so the client textarea reflects
652 709 // exactly what it should hold (the header is added only at render).
653 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']);
654 712
655 713 // Re-read delivery after the write above so the screen reflects the
656 714 // file that now exists rather than the state it was in on load.
657 715 $robots_data['effective'] = $this->identity_manager->get_robots_txt_delivery();
@@ -667,9 +725,9 @@
667 725 ? 'Robots.txt configuration updated and file written successfully'
668 726 : 'Robots.txt configuration updated (file not written: ' . $file_write_result['message'] . ')'
669 727 ], 200);
670 728
671 - } catch (\Exception $e) {
729 + } catch (\Throwable $e) {
672 730 return new WP_Error(
673 731 'update_failed',
674 732 'Robots.txt update failed: ' . $e->getMessage(),
675 733 ['status' => 500]
@@ -868,12 +926,14 @@
868 926 */
869 927 public function validate_identity_settings(WP_REST_Request $request): WP_REST_Response {
870 928 try {
871 929 $settings = $request->get_param('settings');
872 - $tab_context = $request->get_param('tab_context') ?? '';
930 + $tab_context = $request->get_param('tab_context');
873 931
874 - // Validate settings using Site Identity Manager with tab context
875 - $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);
876 936
877 937 return new WP_REST_Response([
878 938 'success' => true,
879 939 'data' => $validation,
@@ -932,8 +992,28 @@
932 992 * Argument validation methods
933 993 */
934 994
935 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 + /**
936 1016 * Get arguments for settings endpoints
937 1017 *
938 1018 * @since 1.0.0
939 1019 *
@@ -973,9 +1053,12 @@
973 1053 return [
974 1054 'template_name' => [
975 1055 'required' => false,
976 1056 'type' => 'string',
977 - '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'],
978 1061 'default' => 'default',
979 1062 'description' => 'Title template to use'
980 1063 ],
981 1064 'data' => [
@@ -1004,9 +1087,11 @@
1004 1087 return [
1005 1088 'breadcrumb_type' => [
1006 1089 'required' => false,
1007 1090 'type' => 'string',
1008 - 'enum' => ['hierarchical', 'taxonomy', 'path', 'custom'],
1091 + // Must match get_available_breadcrumb_types(), which returns
1092 + // 'hierarchical' and nothing else.
1093 + 'enum' => ['hierarchical'],
1009 1094 'default' => 'hierarchical',
1010 1095 'description' => 'Type of breadcrumb navigation to generate'
1011 1096 ],
1012 1097 'options' => [
@@ -1044,8 +1129,29 @@
1044 1129 'required' => false,
1045 1130 'type' => 'boolean',
1046 1131 'default' => true,
1047 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.'
1048 1154 ]
1049 1155 ];
1050 1156 }
1051 1157
@@ -1061,8 +1167,16 @@
1061 1167 'identity_data' => [
1062 1168 'required' => true,
1063 1169 'type' => 'object',
1064 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'
1065 1179 ]
1066 1180 ];
1067 1181 }
1068 1182
@@ -1209,8 +1323,18 @@
1209 1323 'settings' => [
1210 1324 'required' => true,
1211 1325 'type' => 'object',
1212 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'
1213 1337 ]
1214 1338 ];
1215 1339 }
1216 1340