| @@ -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 | |