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 +242 -88 1.0.0 → 2.9.0 View file →
@@ -19,15 +19,22 @@
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;
27 28
29 +// Prevent direct access
30 +if (!defined('ABSPATH')) {
31 + exit;
32 +}
33 +
28 34 // Load CSRF Protection trait
29 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';
30 37
31 38 /**
32 39 * Site Identity API Endpoints Class
33 40 *
@@ -39,8 +46,9 @@
39 46 * @since 1.0.0
40 47 */
41 48 class Site_Identity_Endpoint extends WP_REST_Controller {
42 49 use CSRF_Protection;
50 + use Context_Authorization;
43 51
44 52 /**
45 53 * Site Identity Manager instance
46 54 *
@@ -121,9 +129,10 @@
121 129 [
122 130 [
123 131 'methods' => 'GET',
124 132 'callback' => [$this, 'get_settings'],
125 - 'permission_callback' => [$this, 'check_permissions']
133 + 'permission_callback' => [$this, 'check_permissions'],
134 + 'args' => $this->get_context_route_args()
126 135 ],
127 136 [
128 137 'methods' => 'POST',
129 138 'callback' => [$this, 'update_settings'],
@@ -194,14 +203,18 @@
194 203 [
195 204 [
196 205 'methods' => 'GET',
197 206 'callback' => [$this, 'get_robots_txt'],
198 - 'permission_callback' => [$this, 'check_read_permissions']
207 + // Reading the robots.txt config is a Site Identity operation —
208 + // gate it on the module cap, not the generic 'read' cap.
209 + 'permission_callback' => [$this, 'check_permissions']
199 210 ],
200 211 [
201 212 'methods' => 'POST',
202 213 'callback' => [$this, 'update_robots_txt'],
203 - 'permission_callback' => [$this, 'check_csrf_permissions'],
214 + // Writing robots.txt to the webroot is site-wide — require the
215 + // Site Identity management capability, not just edit_posts.
216 + 'permission_callback' => [$this, 'check_permissions'],
204 217 'args' => $this->get_robots_txt_args()
205 218 ]
206 219 ]
207 220 );
@@ -261,11 +274,8 @@
261 274 ]
262 275 ]
263 276 );
264 277
265 -
266 -
267 -
268 278 }
269 279
270 280 /**
271 281 * Get site identity settings
@@ -272,14 +282,19 @@
272 282 *
273 283 * @since 1.0.0
274 284 *
275 285 * @param WP_REST_Request $request Request object
276 - * @return WP_REST_Response Response object
286 + * @return WP_REST_Response|WP_Error Response object, or the context error
277 287 */
278 - public function get_settings(WP_REST_Request $request): WP_REST_Response {
288 + public function get_settings(WP_REST_Request $request) {
279 289 try {
280 - $context_type = $request->get_param('context_type') ?? 'site';
281 - $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;
282 297
283 298 // Get settings from Site Identity Manager
284 299 $settings = $this->identity_manager->get_settings($context_type, $context_id);
285 300
@@ -315,11 +330,17 @@
315 330 */
316 331 public function update_settings(WP_REST_Request $request) {
317 332 try {
318 333 $settings = $request->get_param('settings');
319 - $context_type = $request->get_param('context_type') ?? 'site';
320 - $context_id = $request->get_param('context_id');
321 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 +
322 343 // Validate settings
323 344 if (empty($settings) || !is_array($settings)) {
324 345 return new WP_Error(
325 346 'invalid_settings',
@@ -348,13 +369,35 @@
348 369
349 370 if (!$update_result) {
350 371 return new WP_Error(
351 372 'update_failed',
352 - 'Failed to update site identity settings',
353 - ['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 + ]
354 378 );
355 379 }
356 380
381 + // If this save changed the robots.txt content/toggle and a physical
382 + // robots.txt exists, keep it in lockstep. The web server serves that
383 + // static file directly (bypassing the robots_txt filter), so without
384 + // this the file goes stale and /robots.txt shows the old content
385 + // while the textarea shows the new — regardless of what the frontend
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).
392 + if ($context_type === 'site'
393 + && (array_key_exists('robots_txt_content', $settings)
394 + || array_key_exists('robots_txt_enabled', $settings)
395 + || array_key_exists('ai_crawler_rules', $settings))
396 + ) {
397 + $this->identity_manager->sync_robots_txt_file();
398 + }
399 +
357 400 // Get updated settings
358 401 $updated_settings = $this->identity_manager->get_settings($context_type, $context_id);
359 402
360 403 return new WP_REST_Response([
@@ -367,9 +410,9 @@
367 410 ],
368 411 'message' => 'Site identity settings updated successfully'
369 412 ], 200);
370 413
371 - } catch (\Exception $e) {
414 + } catch (\Throwable $e) {
372 415 return new WP_Error(
373 416 'update_failed',
374 417 'Settings update failed: ' . $e->getMessage(),
375 418 ['status' => 500]
@@ -513,8 +556,39 @@
513 556
514 557 // Generate robots.txt using Site Identity Manager
515 558 $robots_data = $this->identity_manager->generate_robots_txt($custom_rules);
516 559
560 + // The editor shows the body that is actually being served (physical
561 + // file if present, else the effective content) — header-stripped so
562 + // the auto-generated comment/timestamp never lands in the textarea.
563 + $robots_data['content'] = $this->identity_manager->get_served_robots_body();
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 +
517 591 return new WP_REST_Response([
518 592 'success' => true,
519 593 'data' => $robots_data,
520 594 'message' => 'Robots.txt data retrieved successfully'
@@ -548,8 +622,19 @@
548 622 }
549 623 $custom_rules = $request->get_param('custom_rules') ?? [];
550 624 $enable_management = $request->get_param('enable_management') ?? true;
551 625
626 + // Two callers share this route:
627 + // - "Generate" rebuilds the content from rules and adopts it as the
628 + // stored textarea content ($regenerate = true).
629 + // - A plain save that only needs the physical file re-synced to the
630 + // already-stored textarea content ($regenerate = false).
631 + // Default to true so the historical Generate contract is unchanged.
632 + $regenerate = $request->get_param('regenerate');
633 + if ($regenerate === null) {
634 + $regenerate = true;
635 + }
636 +
552 637 // Validate custom rules format
553 638 if (!is_array($custom_rules)) {
554 639 return new WP_Error(
555 640 'invalid_rules',
@@ -557,51 +642,79 @@
557 642 ['status' => 400]
558 643 );
559 644 }
560 645
561 - // Generate and validate robots.txt
562 - $robots_data = $this->identity_manager->generate_robots_txt($custom_rules);
646 + $settings = ['robots_txt_enabled' => $enable_management];
563 647
564 - if (!empty($robots_data['validation']['errors'])) {
565 - return new WP_Error(
566 - 'validation_failed',
567 - 'Robots.txt validation failed',
568 - [
569 - 'status' => 400,
570 - 'validation_errors' => $robots_data['validation']['errors']
571 - ]
572 - );
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);
573 655 }
574 656
575 - // Update robots.txt settings
576 - $settings = [
577 - 'robots_txt_enabled' => $enable_management,
578 - 'custom_robots_rules' => $custom_rules,
579 - 'robots_txt_content' => $robots_data['content']
580 - ];
657 + if ($regenerate) {
658 + // Generate and validate robots.txt from the rules.
659 + $robots_data = $this->identity_manager->generate_robots_txt($custom_rules);
581 660
661 + if (!empty($robots_data['validation']['errors'])) {
662 + return new WP_Error(
663 + 'validation_failed',
664 + 'Robots.txt validation failed',
665 + [
666 + 'status' => 400,
667 + 'validation_errors' => $robots_data['validation']['errors']
668 + ]
669 + );
670 + }
671 +
672 + // Adopt the freshly generated content as the stored source.
673 + $settings['custom_robots_rules'] = $custom_rules;
674 + $settings['robots_txt_content'] = $robots_data['content'];
675 + }
676 +
582 677 $update_result = $this->identity_manager->save_settings('site', null, $settings);
583 678
584 679 if (!$update_result) {
585 680 return new WP_Error(
586 681 'update_failed',
587 - 'Failed to update robots.txt settings',
588 - ['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 + ]
589 687 );
590 688 }
591 689
592 - // Write robots.txt file to filesystem if management is enabled
690 + // Effective content = the stored textarea content when set, else the
691 + // auto-generated body. This is exactly what the live /robots.txt
692 + // serves, so writing it here keeps the physical file in lockstep.
693 + $effective_content = $this->identity_manager->render_robots_txt();
694 +
695 + // Write robots.txt file to filesystem if management is enabled.
593 696 $file_write_result = ['success' => false, 'message' => 'File writing disabled'];
594 - if ($enable_management && !empty($robots_data['content'])) {
697 + if ($enable_management && $effective_content !== '') {
595 698 $write_to_file = $request->get_param('write_to_file') ?? true;
596 699
597 700 if ($write_to_file) {
598 701 $file_write_result = $this->identity_manager->write_robots_txt(
599 - $robots_data['content']
702 + $effective_content
600 703 );
601 704 }
602 705 }
603 706
707 + $robots_data = $robots_data ?? [];
708 + // Return the header-stripped body so the client textarea reflects
709 + // exactly what it should hold (the header is added only at render).
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']);
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 +
604 717 return new WP_REST_Response([
605 718 'success' => true,
606 719 'data' => [
607 720 'robots_data' => $robots_data,
@@ -612,9 +725,9 @@
612 725 ? 'Robots.txt configuration updated and file written successfully'
613 726 : 'Robots.txt configuration updated (file not written: ' . $file_write_result['message'] . ')'
614 727 ], 200);
615 728
616 - } catch (\Exception $e) {
729 + } catch (\Throwable $e) {
617 730 return new WP_Error(
618 731 'update_failed',
619 732 'Robots.txt update failed: ' . $e->getMessage(),
620 733 ['status' => 500]
@@ -687,25 +800,24 @@
687 800 ['status' => 400]
688 801 );
689 802 }
690 803
691 - // Validate required site data fields
692 - $required_fields = ['site_name', 'site_description', 'tagline'];
693 - foreach ($required_fields as $field) {
694 - if (empty($site_data[$field])) {
695 - return new WP_Error(
696 - 'missing_field',
697 - "Required field '{$field}' is missing or empty",
698 - ['status' => 400]
699 - );
700 - }
804 + // Only the site name is required. An empty description or tagline
805 + // is a valid state — and exactly when AI help is most useful — so
806 + // let the optimizer generate them instead of rejecting the request.
807 + if (empty($site_data['site_name'])) {
808 + return new WP_Error(
809 + 'missing_field',
810 + __('Site name is required to run AI optimization.', 'thinkrank'),
811 + ['status' => 400]
812 + );
701 813 }
702 814
703 - // Sanitize site data
815 + // Sanitize site data (description/tagline may be empty by design)
704 816 $sanitized_site_data = [
705 817 'site_name' => sanitize_text_field($site_data['site_name']),
706 - 'site_description' => sanitize_textarea_field($site_data['site_description']),
707 - 'tagline' => sanitize_text_field($site_data['tagline']),
818 + 'site_description' => sanitize_textarea_field($site_data['site_description'] ?? ''),
819 + 'tagline' => sanitize_text_field($site_data['tagline'] ?? ''),
708 820 'default_meta_description' => sanitize_textarea_field($site_data['default_meta_description'] ?? '')
709 821 ];
710 822
711 823 // Sanitize options
@@ -814,12 +926,14 @@
814 926 */
815 927 public function validate_identity_settings(WP_REST_Request $request): WP_REST_Response {
816 928 try {
817 929 $settings = $request->get_param('settings');
818 - $tab_context = $request->get_param('tab_context') ?? '';
930 + $tab_context = $request->get_param('tab_context');
819 931
820 - // Validate settings using Site Identity Manager with tab context
821 - $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);
822 936
823 937 return new WP_REST_Response([
824 938 'success' => true,
825 939 'data' => $validation,
@@ -833,12 +947,8 @@
833 947 ], 500);
834 948 }
835 949 }
836 950
837 -
838 -
839 -
840 -
841 951 /**
842 952 * Permission callbacks
843 953 */
844 954
@@ -849,36 +959,10 @@
849 959 *
850 960 * @return bool Permission status
851 961 */
852 962 public function check_permissions(): bool {
853 - return current_user_can('manage_options');
963 + return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_site_identity');
854 964 }
855 -
856 - /**
857 - * Helper methods
858 - */
859 -
860 - /**
861 - * Validate context type and ID
862 - *
863 - * @since 1.0.0
864 - *
865 - * @param string $context_type Context type
866 - * @param int|null $context_id Context ID
867 - * @return bool Validation status
868 - */
869 - private function validate_context(string $context_type): bool {
870 - // Only support 'site' context in development stage
871 - return $context_type === 'site';
872 - }
873 -
874 - /**
875 - * Get available title templates
876 - *
877 - * @since 1.0.0
878 - *
879 - * @return array Title templates
880 - */
881 965 private function get_available_title_templates(): array {
882 966 return [
883 967 'default' => [
884 968 'name' => 'Default',
@@ -908,8 +992,28 @@
908 992 * Argument validation methods
909 993 */
910 994
911 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 + /**
912 1016 * Get arguments for settings endpoints
913 1017 *
914 1018 * @since 1.0.0
915 1019 *
@@ -949,9 +1053,12 @@
949 1053 return [
950 1054 'template_name' => [
951 1055 'required' => false,
952 1056 'type' => 'string',
953 - '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'],
954 1061 'default' => 'default',
955 1062 'description' => 'Title template to use'
956 1063 ],
957 1064 'data' => [
@@ -980,9 +1087,11 @@
980 1087 return [
981 1088 'breadcrumb_type' => [
982 1089 'required' => false,
983 1090 'type' => 'string',
984 - 'enum' => ['hierarchical', 'taxonomy', 'path', 'custom'],
1091 + // Must match get_available_breadcrumb_types(), which returns
1092 + // 'hierarchical' and nothing else.
1093 + 'enum' => ['hierarchical'],
985 1094 'default' => 'hierarchical',
986 1095 'description' => 'Type of breadcrumb navigation to generate'
987 1096 ],
988 1097 'options' => [
@@ -1014,8 +1123,35 @@
1014 1123 'required' => false,
1015 1124 'type' => 'boolean',
1016 1125 'default' => true,
1017 1126 'description' => 'Enable automatic robots.txt management'
1127 + ],
1128 + 'regenerate' => [
1129 + 'required' => false,
1130 + 'type' => 'boolean',
1131 + 'default' => true,
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.'
1018 1154 ]
1019 1155 ];
1020 1156 }
1021 1157
@@ -1031,8 +1167,16 @@
1031 1167 'identity_data' => [
1032 1168 'required' => true,
1033 1169 'type' => 'object',
1034 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'
1035 1179 ]
1036 1180 ];
1037 1181 }
1038 1182
@@ -1179,8 +1323,18 @@
1179 1323 'settings' => [
1180 1324 'required' => true,
1181 1325 'type' => 'object',
1182 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'
1183 1337 ]
1184 1338 ];
1185 1339 }
1186 1340
@@ -1195,9 +1349,9 @@
1195 1349 $rate_key = "thinkrank_robots_rate_{$user_id}";
1196 1350
1197 1351 $requests = get_transient($rate_key) ?: 0;
1198 1352
1199 - if ($requests >= 3) { // Max 3 requests per 5 minutes
1353 + if ($requests >= 20) { // Max 20 requests per 5 minutes
1200 1354 return false;
1201 1355 }
1202 1356
1203 1357 set_transient($rate_key, $requests + 1, 5 * MINUTE_IN_SECONDS);