PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.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 All 51 releases
← All changes | includes/seo/class-schema-input-validator.php +144 -32 1.29.0 → 2.10.0 View file →
@@ -118,8 +118,48 @@
118 118 'VideoObject' => [
119 119 'required_fields' => ['@type', 'name', 'thumbnailUrl', 'uploadDate'],
120 120 'optional_fields' => ['description', 'contentUrl', 'embedUrl', 'duration', 'url'],
121 121 'max_length' => ['name' => 110, 'description' => 160]
122 + ],
123 + // Offered by the metabox Schema Type dropdown and registered in
124 + // Schema_Factory, but missing here — so a Review could be generated and
125 + // never deployed (#462). Field list mirrors Schema_Factory::Review.
126 + 'Review' => [
127 + 'required_fields' => ['@type', 'itemReviewed', 'reviewRating', 'author'],
128 + 'optional_fields' => ['reviewBody', 'datePublished', 'publisher', 'name', 'url'],
129 + 'max_length' => ['name' => 110, 'reviewBody' => 500]
130 + ],
131 + // The WebPage family. #624 made all four selectable in the metabox and
132 + // taught the generate/validate/optimize/preview routes, Schema_Builder,
133 + // Schema_Factory and Schema_Graph about them — but not this array, and
134 + // deploy_schema_markup() gates every entry on it. So each one generated
135 + // cleanly, validated at a score of 100, reported deployment_ready, then
136 + // failed deployment with "Invalid schema type: AboutPage" and no row
137 + // written. Exactly the #462 Review bug, one array and two releases
138 + // later, which is why SchemaInputValidatorCoverageTest now scans the
139 + // dropdown instead of trusting this list to be extended by hand.
140 + //
141 + // `name` and `url` are the two populate_webpage_schema() always sets;
142 + // the rest are conditional, so they are optional here.
143 + 'WebPage' => [
144 + 'required_fields' => ['@type', 'name', 'url'],
145 + 'optional_fields' => ['description', 'datePublished', 'dateModified', 'isPartOf', 'breadcrumb', 'primaryImageOfPage', 'inLanguage', 'mainEntity'],
146 + 'max_length' => ['name' => 110, 'description' => 160]
147 + ],
148 + 'AboutPage' => [
149 + 'required_fields' => ['@type', 'name', 'url'],
150 + 'optional_fields' => ['description', 'datePublished', 'dateModified', 'isPartOf', 'breadcrumb', 'primaryImageOfPage', 'inLanguage', 'mainEntity'],
151 + 'max_length' => ['name' => 110, 'description' => 160]
152 + ],
153 + 'ContactPage' => [
154 + 'required_fields' => ['@type', 'name', 'url'],
155 + 'optional_fields' => ['description', 'datePublished', 'dateModified', 'isPartOf', 'breadcrumb', 'primaryImageOfPage', 'inLanguage', 'mainEntity'],
156 + 'max_length' => ['name' => 110, 'description' => 160]
157 + ],
158 + 'ProfilePage' => [
159 + 'required_fields' => ['@type', 'name', 'url'],
160 + 'optional_fields' => ['description', 'datePublished', 'dateModified', 'isPartOf', 'breadcrumb', 'primaryImageOfPage', 'inLanguage', 'mainEntity'],
161 + 'max_length' => ['name' => 110, 'description' => 160]
122 162 ]
123 163 ];
124 164
125 165 /**
@@ -363,15 +403,29 @@
363 403 $result['errors'][] = 'Invalid @context value. Must be "https://schema.org"';
364 404 $result['valid'] = false;
365 405 }
366 406
367 - // Check for required @type
407 + // Check for required @type.
408 + // `@type` may be an array — "@type": ["Product","Offer"] is valid
409 + // JSON-LD. Comparing an array against a string emitted an "Array to
410 + // string conversion" warning and always failed (#468), so match if the
411 + // expected type appears anywhere in the list.
368 412 if (!isset($schema_data['@type'])) {
369 413 $result['errors'][] = 'Missing required @type field';
370 414 $result['valid'] = false;
371 - } elseif ($schema_data['@type'] !== $schema_type) {
372 - $result['errors'][] = "Schema @type '{$schema_data['@type']}' does not match expected type '{$schema_type}'";
373 - $result['valid'] = false;
415 + } else {
416 + $declared_types = is_array($schema_data['@type'])
417 + ? array_map('strval', $schema_data['@type'])
418 + : [(string) $schema_data['@type']];
419 +
420 + if (!in_array($schema_type, $declared_types, true)) {
421 + $result['errors'][] = sprintf(
422 + "Schema @type '%s' does not match expected type '%s'",
423 + implode(', ', $declared_types),
424 + $schema_type
425 + );
426 + $result['valid'] = false;
427 + }
374 428 }
375 429
376 430 return $result;
377 431 }
@@ -534,8 +588,26 @@
534 588 private function validate_data_formats(array $schema_data, string $schema_type): array {
535 589 $result = ['valid' => true, 'errors' => [], 'warnings' => []];
536 590
537 591 foreach ($schema_data as $field => $value) {
592 + // sameAs is a list, so its members never reached the string branch
593 + // below and free text entered in a social-profile field saved
594 + // cleanly, then shipped as invalid structured data (#480).
595 + if (is_array($value) && in_array($field, ['url', 'sameAs', 'logo', 'image'], true)) {
596 + foreach ($value as $item) {
597 + if (!is_string($item) || '' === trim($item)) {
598 + continue;
599 + }
600 +
601 + if (!$this->is_valid_url($item)) {
602 + $result['errors'][] = "Invalid URL format for field: {$field} ({$item})";
603 + $result['valid'] = false;
604 + }
605 + }
606 +
607 + continue;
608 + }
609 +
538 610 if (is_string($value)) {
539 611 // Validate URLs
540 612 if (in_array($field, ['url', 'sameAs', 'logo', 'image'], true) && !empty($value)) {
541 613 if (!$this->is_valid_url($value)) {
@@ -721,14 +793,25 @@
721 793 $result['errors'][] = 'Invalid user or user not logged in';
722 794 return $result;
723 795 }
724 796
725 - // Check operation-specific permissions
797 + // Check operation-specific permissions.
798 + //
799 + // #457 loosened the route permission_callbacks to the delegable
800 + // `thinkrank_schema` capability, but these handler-level checks still
801 + // demanded edit_posts / publish_posts / manage_options — so a role
802 + // granted Schema access could generate and validate but was denied on
803 + // deploy, bulk operations and everything site-context. That is exactly
804 + // the symptom #457 set out to fix (#470). A holder of thinkrank_schema
805 + // satisfies any schema operation; the built-in caps remain as the
806 + // fallback for roles that never went through the Role Manager.
807 + $has_schema_cap = user_can($user_id, 'thinkrank_schema');
808 +
726 809 switch ($operation) {
727 810 case 'generate':
728 811 case 'validate':
729 812 case 'optimize':
730 - if (!user_can($user_id, 'edit_posts')) {
813 + if (!$has_schema_cap && !user_can($user_id, 'edit_posts')) {
731 814 $result['errors'][] = 'Insufficient permissions for schema generation/validation';
732 815 return $result;
733 816 }
734 817 break;
@@ -733,9 +816,9 @@
733 816 }
734 817 break;
735 818
736 819 case 'deploy':
737 - if (!user_can($user_id, 'publish_posts')) {
820 + if (!$has_schema_cap && !user_can($user_id, 'publish_posts')) {
738 821 $result['errors'][] = 'Insufficient permissions for schema deployment';
739 822 return $result;
740 823 }
741 824 break;
@@ -741,9 +824,9 @@
741 824 break;
742 825
743 826 case 'manage_settings':
744 827 case 'bulk_operations':
745 - if (!user_can($user_id, 'manage_options')) {
828 + if (!$has_schema_cap && !user_can($user_id, 'manage_options')) {
746 829 $result['errors'][] = 'Insufficient permissions for schema management';
747 830 return $result;
748 831 }
749 832 break;
@@ -808,10 +891,14 @@
808 891 $result['errors'][] = "Invalid context ID: {$context_id}";
809 892 return $result;
810 893 }
811 894
812 - // SECURITY: Check context ownership
813 - if ($user_id && !$this->validate_context_ownership($post, $user_id)) {
895 + // SECURITY: Check context ownership.
896 + // Fails closed on a missing user — a security helper that waves the
897 + // check through when it cannot identify the caller is the wrong way
898 + // round. Every caller passes a real ID, so this only tightens an
899 + // unreachable path.
900 + if (!$user_id || !$this->validate_context_ownership($post, $user_id)) {
814 901 $result['errors'][] = "Access denied: You don't have permission to modify this {$context_type}";
815 902 return $result;
816 903 }
817 904 } else {
@@ -816,10 +903,18 @@
816 903 }
817 904 } else {
818 905 $context_id = null; // Site context doesn't use ID
819 906
820 - // SECURITY: Check site-level permissions for site context
821 - if ($user_id && !current_user_can('manage_options')) {
907 + // SECURITY: Check site-level permissions for site context.
908 + // user_can($user_id, …) rather than current_user_can() so this
909 + // agrees with the rest of the validator outside a REST request,
910 + // where the current user and $user_id can differ (cron, CLI).
911 + //
912 + // Accepts the delegable `thinkrank_schema` capability as well as
913 + // manage_options: /schema/settings already lets a delegated role
914 + // edit site schema settings, so blocking site-context generate and
915 + // deploy for the same role was inconsistent (#470).
916 + if (!$user_id || (!user_can($user_id, 'thinkrank_schema') && !user_can($user_id, 'manage_options'))) {
822 917 $result['errors'][] = 'Access denied: You need administrator privileges for site-level schema operations';
823 918 return $result;
824 919 }
825 920 }
@@ -842,25 +937,23 @@
842 937 * @param int $user_id User ID
843 938 * @return bool Whether user has permission
844 939 */
845 940 private function validate_context_ownership(\WP_Post $post, int $user_id): bool {
846 - // Check if user can edit this specific post
847 - if (current_user_can('edit_post', $post->ID)) {
848 - return true;
849 - }
850 -
851 - // Check if user is the post author
852 - if ((int) $post->post_author === (int) $user_id) {
853 - return true;
854 - }
855 -
856 - // Check if user has general edit capabilities for this post type
857 - $post_type_object = get_post_type_object($post->post_type);
858 - if ($post_type_object && current_user_can($post_type_object->cap->edit_posts)) {
859 - return true;
860 - }
861 -
862 - return false;
941 + // `edit_post` is a meta capability: map_meta_cap() already resolves
942 + // authorship, published state, and edit_others_posts for this specific
943 + // post. It is the whole check.
944 + //
945 + // Two fallbacks used to sit under it and between them defeated the
946 + // function. One granted access on authorship alone, which hands a
947 + // Contributor back a post they lost edit rights to once it published.
948 + // The other granted access to anyone holding the post type's *general*
949 + // edit_posts capability — a cap every Author and Contributor has, that
950 + // says nothing about this post — so ownership validation returned true
951 + // for every post on the site (#326).
952 + //
953 + // user_can() rather than current_user_can() so the method honours the
954 + // $user_id it was handed, matching validate_user_permissions().
955 + return user_can($user_id, 'edit_post', $post->ID);
863 956 }
864 957
865 958 /**
866 959 * Validate JSON depth to prevent JSON bomb attacks
@@ -896,14 +989,33 @@
896 989 * @return array Sanitized options
897 990 */
898 991 public function sanitize_options(array $options): array {
899 992 $sanitized = [];
993 + // Anything omitted here is dropped before the manager sees it, which is
994 + // why apply_content_schema_settings_from_options() and the per-request
995 + // schema-type opt-in were unreachable from REST (#470). The list now
996 + // covers every option the generate path actually reads.
997 + //
998 + // `validation_level` previously allowed 'basic' and rejected 'lenient',
999 + // disagreeing with Schema_Settings_Config, validate_settings() and the
1000 + // update-settings ability, which all use 'lenient'.
1001 + // `deployment_method` no longer advertises microdata/rdfa, which
1002 + // determine_deployment_method() hardcodes away to json_ld anyway.
900 1003 $allowed_options = [
901 - 'deployment_method' => ['json_ld', 'microdata', 'rdfa'],
902 - 'validation_level' => ['strict', 'moderate', 'basic'],
1004 + 'deployment_method' => ['json_ld'],
1005 + 'validation_level' => ['strict', 'moderate', 'lenient'],
903 1006 'include_meta' => 'boolean',
904 1007 'minify_output' => 'boolean',
905 - 'cache_duration' => 'integer'
1008 + 'cache_duration' => 'integer',
1009 + 'rich_snippets_optimization' => 'boolean',
1010 + 'knowledge_graph' => 'boolean',
1011 + 'auto_generate_schema' => 'boolean',
1012 + 'enable_article_schema' => 'boolean',
1013 + 'enable_faq_schema' => 'boolean',
1014 + 'enable_howto_schema' => 'boolean',
1015 + 'enable_product_schema' => 'boolean',
1016 + 'enable_local_business' => 'boolean',
1017 + 'enable_breadcrumbs_schema' => 'boolean',
906 1018 ];
907 1019
908 1020 foreach ($options as $key => $value) {
909 1021 $sanitized_key = sanitize_key($key);