PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.3
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.3
2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.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 All 58 releases
← All changes | includes/seo/class-schema-input-validator.php +213 -35 1.29.0 → 2.14.3 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 /**
@@ -189,8 +229,16 @@
189 229 'errors' => [],
190 230 'warnings' => []
191 231 ];
192 232
233 + // The deploy routes read the type from the node's @type, and a
234 + // LocalBusiness is published under the business type chosen in Local
235 + // SEO ("Restaurant", #1005). Validate it as the LocalBusiness it is;
236 + // the @type match below accepts the subtype.
237 + if (\ThinkRank\Config\Local_Business_Types_Config::is_local_business($schema_type)) {
238 + $schema_type = 'LocalBusiness';
239 + }
240 +
193 241 try {
194 242 // 1. Validate payload size to prevent DoS attacks
195 243 $size_validation = $this->validate_payload_size($schema_data);
196 244 if (!$size_validation['valid']) {
@@ -226,9 +274,9 @@
226 274 $result['errors'] = array_merge($result['errors'], $field_validation['errors']);
227 275 }
228 276
229 277 // 6. Validate data types and formats
230 - $format_validation = $this->validate_data_formats($sanitized_data, $schema_type);
278 + $format_validation = $this->validate_data_formats($sanitized_data, $schema_type, $schema_data);
231 279 if (!$format_validation['valid']) {
232 280 $result['errors'] = array_merge($result['errors'], $format_validation['errors']);
233 281 }
234 282 $result['warnings'] = array_merge($result['warnings'], $format_validation['warnings']);
@@ -363,15 +411,34 @@
363 411 $result['errors'][] = 'Invalid @context value. Must be "https://schema.org"';
364 412 $result['valid'] = false;
365 413 }
366 414
367 - // Check for required @type
415 + // Check for required @type.
416 + // `@type` may be an array — "@type": ["Product","Offer"] is valid
417 + // JSON-LD. Comparing an array against a string emitted an "Array to
418 + // string conversion" warning and always failed (#468), so match if the
419 + // expected type appears anywhere in the list.
368 420 if (!isset($schema_data['@type'])) {
369 421 $result['errors'][] = 'Missing required @type field';
370 422 $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;
423 + } else {
424 + $declared_types = is_array($schema_data['@type'])
425 + ? array_map('strval', $schema_data['@type'])
426 + : [(string) $schema_data['@type']];
427 +
428 + // A LocalBusiness is deployed under the business subtype the site
429 + // chose (#1005).
430 + $is_local_business_subtype = 'LocalBusiness' === $schema_type
431 + && [] !== array_filter($declared_types, [\ThinkRank\Config\Local_Business_Types_Config::class, 'is_local_business']);
432 +
433 + if (!$is_local_business_subtype && !in_array($schema_type, $declared_types, true)) {
434 + $result['errors'][] = sprintf(
435 + "Schema @type '%s' does not match expected type '%s'",
436 + implode(', ', $declared_types),
437 + $schema_type
438 + );
439 + $result['valid'] = false;
440 + }
374 441 }
375 442
376 443 return $result;
377 444 }
@@ -528,14 +595,45 @@
528 595 * @since 1.0.0
529 596 *
530 597 * @param array $schema_data Schema data
531 598 * @param string $schema_type Schema type
599 + * @param array $raw_data Schema data as submitted, before sanitization.
532 600 * @return array Validation result
533 601 */
534 - private function validate_data_formats(array $schema_data, string $schema_type): array {
602 + private function validate_data_formats(array $schema_data, string $schema_type, array $raw_data = []): array {
535 603 $result = ['valid' => true, 'errors' => [], 'warnings' => []];
536 604
537 605 foreach ($schema_data as $field => $value) {
606 + // sameAs is a list, so its members never reached the string branch
607 + // below and free text entered in a social-profile field saved
608 + // cleanly, then shipped as invalid structured data (#480).
609 + if (is_array($value) && in_array($field, ['url', 'sameAs', 'logo', 'image'], true)) {
610 + // Validated after sanitization, but reported as entered:
611 + // esc_url_raw() turns "not a url" into "http://not%20a%20url",
612 + // which the user never typed (#949 review).
613 + $items = $this->url_candidates($value);
614 + $raw_items = is_array($raw_data[$field] ?? null) ? $this->url_candidates($raw_data[$field]) : [];
615 + if (count($raw_items) !== count($items)) {
616 + // A non-string member sanitized into a string would shift
617 + // the positions; fall back rather than name the wrong one.
618 + $raw_items = [];
619 + }
620 +
621 + foreach ($items as $index => $item) {
622 + if ('' === trim($item)) {
623 + continue;
624 + }
625 +
626 + if (!$this->is_valid_url($item)) {
627 + $shown = $raw_items[$index] ?? $item;
628 + $result['errors'][] = "Invalid URL format for field: {$field} ({$shown})";
629 + $result['valid'] = false;
630 + }
631 + }
632 +
633 + continue;
634 + }
635 +
538 636 if (is_string($value)) {
539 637 // Validate URLs
540 638 if (in_array($field, ['url', 'sameAs', 'logo', 'image'], true) && !empty($value)) {
541 639 if (!$this->is_valid_url($value)) {
@@ -564,8 +662,47 @@
564 662 return $result;
565 663 }
566 664
567 665 /**
666 + * The URL strings inside an array-valued URL field.
667 + *
668 + * The field is either a list (`sameAs`, several `image` URLs) or a single
669 + * node such as the `ImageObject` Schema_Builder emits for a configured
670 + * logo. Checking every member of a node URL-checked its `@type` and
671 + * `width`, so "ImageObject" failed as an invalid URL and the deploy route
672 + * skipped — then retired — the site's Organization (#949). Only a node's
673 + * `url` / `contentUrl` are URLs; a list may hold strings or such nodes.
674 + *
675 + * @since 2.14.1
676 + *
677 + * @param array $value Array-valued URL field.
678 + * @return string[] URL strings to validate.
679 + */
680 + private function url_candidates(array $value): array {
681 + $node_urls = static function (array $node): array {
682 + return array_values(array_filter(
683 + [$node['url'] ?? null, $node['contentUrl'] ?? null],
684 + 'is_string'
685 + ));
686 + };
687 +
688 + if (array_values($value) !== $value) {
689 + return $node_urls($value);
690 + }
691 +
692 + $urls = [];
693 + foreach ($value as $item) {
694 + if (is_string($item)) {
695 + $urls[] = $item;
696 + } elseif (is_array($item)) {
697 + $urls = array_merge($urls, $node_urls($item));
698 + }
699 + }
700 +
701 + return $urls;
702 + }
703 +
704 + /**
568 705 * Validate content lengths
569 706 *
570 707 * @since 1.0.0
571 708 *
@@ -600,9 +737,9 @@
600 737 * @return bool Validation result
601 738 */
602 739 private function is_valid_url(string $url): bool {
603 740 // Basic URL validation
604 - if (!filter_var($url, FILTER_VALIDATE_URL)) {
741 + if (!\ThinkRank\Core\Url_Validator::is_valid($url)) {
605 742 return false;
606 743 }
607 744
608 745 // Check allowed protocols
@@ -721,14 +858,25 @@
721 858 $result['errors'][] = 'Invalid user or user not logged in';
722 859 return $result;
723 860 }
724 861
725 - // Check operation-specific permissions
862 + // Check operation-specific permissions.
863 + //
864 + // #457 loosened the route permission_callbacks to the delegable
865 + // `thinkrank_schema` capability, but these handler-level checks still
866 + // demanded edit_posts / publish_posts / manage_options — so a role
867 + // granted Schema access could generate and validate but was denied on
868 + // deploy, bulk operations and everything site-context. That is exactly
869 + // the symptom #457 set out to fix (#470). A holder of thinkrank_schema
870 + // satisfies any schema operation; the built-in caps remain as the
871 + // fallback for roles that never went through the Role Manager.
872 + $has_schema_cap = user_can($user_id, 'thinkrank_schema');
873 +
726 874 switch ($operation) {
727 875 case 'generate':
728 876 case 'validate':
729 877 case 'optimize':
730 - if (!user_can($user_id, 'edit_posts')) {
878 + if (!$has_schema_cap && !user_can($user_id, 'edit_posts')) {
731 879 $result['errors'][] = 'Insufficient permissions for schema generation/validation';
732 880 return $result;
733 881 }
734 882 break;
@@ -733,9 +881,9 @@
733 881 }
734 882 break;
735 883
736 884 case 'deploy':
737 - if (!user_can($user_id, 'publish_posts')) {
885 + if (!$has_schema_cap && !user_can($user_id, 'publish_posts')) {
738 886 $result['errors'][] = 'Insufficient permissions for schema deployment';
739 887 return $result;
740 888 }
741 889 break;
@@ -741,9 +889,9 @@
741 889 break;
742 890
743 891 case 'manage_settings':
744 892 case 'bulk_operations':
745 - if (!user_can($user_id, 'manage_options')) {
893 + if (!$has_schema_cap && !user_can($user_id, 'manage_options')) {
746 894 $result['errors'][] = 'Insufficient permissions for schema management';
747 895 return $result;
748 896 }
749 897 break;
@@ -808,10 +956,14 @@
808 956 $result['errors'][] = "Invalid context ID: {$context_id}";
809 957 return $result;
810 958 }
811 959
812 - // SECURITY: Check context ownership
813 - if ($user_id && !$this->validate_context_ownership($post, $user_id)) {
960 + // SECURITY: Check context ownership.
961 + // Fails closed on a missing user — a security helper that waves the
962 + // check through when it cannot identify the caller is the wrong way
963 + // round. Every caller passes a real ID, so this only tightens an
964 + // unreachable path.
965 + if (!$user_id || !$this->validate_context_ownership($post, $user_id)) {
814 966 $result['errors'][] = "Access denied: You don't have permission to modify this {$context_type}";
815 967 return $result;
816 968 }
817 969 } else {
@@ -816,10 +968,18 @@
816 968 }
817 969 } else {
818 970 $context_id = null; // Site context doesn't use ID
819 971
820 - // SECURITY: Check site-level permissions for site context
821 - if ($user_id && !current_user_can('manage_options')) {
972 + // SECURITY: Check site-level permissions for site context.
973 + // user_can($user_id, …) rather than current_user_can() so this
974 + // agrees with the rest of the validator outside a REST request,
975 + // where the current user and $user_id can differ (cron, CLI).
976 + //
977 + // Accepts the delegable `thinkrank_schema` capability as well as
978 + // manage_options: /schema/settings already lets a delegated role
979 + // edit site schema settings, so blocking site-context generate and
980 + // deploy for the same role was inconsistent (#470).
981 + if (!$user_id || (!user_can($user_id, 'thinkrank_schema') && !user_can($user_id, 'manage_options'))) {
822 982 $result['errors'][] = 'Access denied: You need administrator privileges for site-level schema operations';
823 983 return $result;
824 984 }
825 985 }
@@ -842,25 +1002,23 @@
842 1002 * @param int $user_id User ID
843 1003 * @return bool Whether user has permission
844 1004 */
845 1005 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;
1006 + // `edit_post` is a meta capability: map_meta_cap() already resolves
1007 + // authorship, published state, and edit_others_posts for this specific
1008 + // post. It is the whole check.
1009 + //
1010 + // Two fallbacks used to sit under it and between them defeated the
1011 + // function. One granted access on authorship alone, which hands a
1012 + // Contributor back a post they lost edit rights to once it published.
1013 + // The other granted access to anyone holding the post type's *general*
1014 + // edit_posts capability — a cap every Author and Contributor has, that
1015 + // says nothing about this post — so ownership validation returned true
1016 + // for every post on the site (#326).
1017 + //
1018 + // user_can() rather than current_user_can() so the method honours the
1019 + // $user_id it was handed, matching validate_user_permissions().
1020 + return user_can($user_id, 'edit_post', $post->ID);
863 1021 }
864 1022
865 1023 /**
866 1024 * Validate JSON depth to prevent JSON bomb attacks
@@ -896,14 +1054,34 @@
896 1054 * @return array Sanitized options
897 1055 */
898 1056 public function sanitize_options(array $options): array {
899 1057 $sanitized = [];
1058 + // Anything omitted here is dropped before the manager sees it, which is
1059 + // why apply_content_schema_settings_from_options() and the per-request
1060 + // schema-type opt-in were unreachable from REST (#470). The list now
1061 + // covers every option the generate path actually reads.
1062 + //
1063 + // `validation_level` previously allowed 'basic' and rejected 'lenient',
1064 + // disagreeing with Schema_Settings_Config, validate_settings() and the
1065 + // update-settings ability, which all use 'lenient'.
1066 + // `deployment_method` no longer advertises microdata/rdfa, which
1067 + // determine_deployment_method() hardcodes away to json_ld anyway.
900 1068 $allowed_options = [
901 - 'deployment_method' => ['json_ld', 'microdata', 'rdfa'],
902 - 'validation_level' => ['strict', 'moderate', 'basic'],
1069 + 'deployment_method' => ['json_ld'],
1070 + 'validation_level' => ['strict', 'moderate', 'lenient'],
903 1071 'include_meta' => 'boolean',
904 1072 'minify_output' => 'boolean',
905 - 'cache_duration' => 'integer'
1073 + 'cache_duration' => 'integer',
1074 + 'rich_snippets_optimization' => 'boolean',
1075 + 'knowledge_graph' => 'boolean',
1076 + 'auto_generate_schema' => 'boolean',
1077 + 'enable_article_schema' => 'boolean',
1078 + 'enable_faq_schema' => 'boolean',
1079 + 'enable_howto_schema' => 'boolean',
1080 + 'enable_product_schema' => 'boolean',
1081 + 'enable_local_business' => 'boolean',
1082 + 'enable_breadcrumbs_schema' => 'boolean',
1083 + 'enable_accordion_faq_schema' => 'boolean',
906 1084 ];
907 1085
908 1086 foreach ($options as $key => $value) {
909 1087 $sanitized_key = sanitize_key($key);