| @@ -118,8 +118,16 @@ | ||
| 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] | |
| 122 | 130 | ] |
| 123 | 131 | ]; |
| 124 | 132 | |
| 125 | 133 | /** |
| @@ -363,15 +371,29 @@ | ||
| 363 | 371 | $result['errors'][] = 'Invalid @context value. Must be "https://schema.org"'; |
| 364 | 372 | $result['valid'] = false; |
| 365 | 373 | } |
| 366 | 374 | |
| 367 | - // Check for required @type | |
| 375 | + // Check for required @type. | |
| 376 | + // `@type` may be an array — "@type": ["Product","Offer"] is valid | |
| 377 | + // JSON-LD. Comparing an array against a string emitted an "Array to | |
| 378 | + // string conversion" warning and always failed (#468), so match if the | |
| 379 | + // expected type appears anywhere in the list. | |
| 368 | 380 | if (!isset($schema_data['@type'])) { |
| 369 | 381 | $result['errors'][] = 'Missing required @type field'; |
| 370 | 382 | $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; | |
| 383 | + } else { | |
| 384 | + $declared_types = is_array($schema_data['@type']) | |
| 385 | + ? array_map('strval', $schema_data['@type']) | |
| 386 | + : [(string) $schema_data['@type']]; | |
| 387 | + | |
| 388 | + if (!in_array($schema_type, $declared_types, true)) { | |
| 389 | + $result['errors'][] = sprintf( | |
| 390 | + "Schema @type '%s' does not match expected type '%s'", | |
| 391 | + implode(', ', $declared_types), | |
| 392 | + $schema_type | |
| 393 | + ); | |
| 394 | + $result['valid'] = false; | |
| 395 | + } | |
| 374 | 396 | } |
| 375 | 397 | |
| 376 | 398 | return $result; |
| 377 | 399 | } |
| @@ -534,8 +556,26 @@ | ||
| 534 | 556 | private function validate_data_formats(array $schema_data, string $schema_type): array { |
| 535 | 557 | $result = ['valid' => true, 'errors' => [], 'warnings' => []]; |
| 536 | 558 | |
| 537 | 559 | foreach ($schema_data as $field => $value) { |
| 560 | + // sameAs is a list, so its members never reached the string branch | |
| 561 | + // below and free text entered in a social-profile field saved | |
| 562 | + // cleanly, then shipped as invalid structured data (#480). | |
| 563 | + if (is_array($value) && in_array($field, ['url', 'sameAs', 'logo', 'image'], true)) { | |
| 564 | + foreach ($value as $item) { | |
| 565 | + if (!is_string($item) || '' === trim($item)) { | |
| 566 | + continue; | |
| 567 | + } | |
| 568 | + | |
| 569 | + if (!$this->is_valid_url($item)) { | |
| 570 | + $result['errors'][] = "Invalid URL format for field: {$field} ({$item})"; | |
| 571 | + $result['valid'] = false; | |
| 572 | + } | |
| 573 | + } | |
| 574 | + | |
| 575 | + continue; | |
| 576 | + } | |
| 577 | + | |
| 538 | 578 | if (is_string($value)) { |
| 539 | 579 | // Validate URLs |
| 540 | 580 | if (in_array($field, ['url', 'sameAs', 'logo', 'image'], true) && !empty($value)) { |
| 541 | 581 | if (!$this->is_valid_url($value)) { |
| @@ -721,14 +761,25 @@ | ||
| 721 | 761 | $result['errors'][] = 'Invalid user or user not logged in'; |
| 722 | 762 | return $result; |
| 723 | 763 | } |
| 724 | 764 | |
| 725 | - // Check operation-specific permissions | |
| 765 | + // Check operation-specific permissions. | |
| 766 | + // | |
| 767 | + // #457 loosened the route permission_callbacks to the delegable | |
| 768 | + // `thinkrank_schema` capability, but these handler-level checks still | |
| 769 | + // demanded edit_posts / publish_posts / manage_options — so a role | |
| 770 | + // granted Schema access could generate and validate but was denied on | |
| 771 | + // deploy, bulk operations and everything site-context. That is exactly | |
| 772 | + // the symptom #457 set out to fix (#470). A holder of thinkrank_schema | |
| 773 | + // satisfies any schema operation; the built-in caps remain as the | |
| 774 | + // fallback for roles that never went through the Role Manager. | |
| 775 | + $has_schema_cap = user_can($user_id, 'thinkrank_schema'); | |
| 776 | + | |
| 726 | 777 | switch ($operation) { |
| 727 | 778 | case 'generate': |
| 728 | 779 | case 'validate': |
| 729 | 780 | case 'optimize': |
| 730 | - if (!user_can($user_id, 'edit_posts')) { | |
| 781 | + if (!$has_schema_cap && !user_can($user_id, 'edit_posts')) { | |
| 731 | 782 | $result['errors'][] = 'Insufficient permissions for schema generation/validation'; |
| 732 | 783 | return $result; |
| 733 | 784 | } |
| 734 | 785 | break; |
| @@ -733,9 +784,9 @@ | ||
| 733 | 784 | } |
| 734 | 785 | break; |
| 735 | 786 | |
| 736 | 787 | case 'deploy': |
| 737 | - if (!user_can($user_id, 'publish_posts')) { | |
| 788 | + if (!$has_schema_cap && !user_can($user_id, 'publish_posts')) { | |
| 738 | 789 | $result['errors'][] = 'Insufficient permissions for schema deployment'; |
| 739 | 790 | return $result; |
| 740 | 791 | } |
| 741 | 792 | break; |
| @@ -741,9 +792,9 @@ | ||
| 741 | 792 | break; |
| 742 | 793 | |
| 743 | 794 | case 'manage_settings': |
| 744 | 795 | case 'bulk_operations': |
| 745 | - if (!user_can($user_id, 'manage_options')) { | |
| 796 | + if (!$has_schema_cap && !user_can($user_id, 'manage_options')) { | |
| 746 | 797 | $result['errors'][] = 'Insufficient permissions for schema management'; |
| 747 | 798 | return $result; |
| 748 | 799 | } |
| 749 | 800 | break; |
| @@ -824,9 +875,14 @@ | ||
| 824 | 875 | // SECURITY: Check site-level permissions for site context. |
| 825 | 876 | // user_can($user_id, …) rather than current_user_can() so this |
| 826 | 877 | // agrees with the rest of the validator outside a REST request, |
| 827 | 878 | // where the current user and $user_id can differ (cron, CLI). |
| 828 | - if (!$user_id || !user_can($user_id, 'manage_options')) { | |
| 879 | + // | |
| 880 | + // Accepts the delegable `thinkrank_schema` capability as well as | |
| 881 | + // manage_options: /schema/settings already lets a delegated role | |
| 882 | + // edit site schema settings, so blocking site-context generate and | |
| 883 | + // deploy for the same role was inconsistent (#470). | |
| 884 | + if (!$user_id || (!user_can($user_id, 'thinkrank_schema') && !user_can($user_id, 'manage_options'))) { | |
| 829 | 885 | $result['errors'][] = 'Access denied: You need administrator privileges for site-level schema operations'; |
| 830 | 886 | return $result; |
| 831 | 887 | } |
| 832 | 888 | } |
| @@ -901,14 +957,33 @@ | ||
| 901 | 957 | * @return array Sanitized options |
| 902 | 958 | */ |
| 903 | 959 | public function sanitize_options(array $options): array { |
| 904 | 960 | $sanitized = []; |
| 961 | + // Anything omitted here is dropped before the manager sees it, which is | |
| 962 | + // why apply_content_schema_settings_from_options() and the per-request | |
| 963 | + // schema-type opt-in were unreachable from REST (#470). The list now | |
| 964 | + // covers every option the generate path actually reads. | |
| 965 | + // | |
| 966 | + // `validation_level` previously allowed 'basic' and rejected 'lenient', | |
| 967 | + // disagreeing with Schema_Settings_Config, validate_settings() and the | |
| 968 | + // update-settings ability, which all use 'lenient'. | |
| 969 | + // `deployment_method` no longer advertises microdata/rdfa, which | |
| 970 | + // determine_deployment_method() hardcodes away to json_ld anyway. | |
| 905 | 971 | $allowed_options = [ |
| 906 | - 'deployment_method' => ['json_ld', 'microdata', 'rdfa'], | |
| 907 | - 'validation_level' => ['strict', 'moderate', 'basic'], | |
| 972 | + 'deployment_method' => ['json_ld'], | |
| 973 | + 'validation_level' => ['strict', 'moderate', 'lenient'], | |
| 908 | 974 | 'include_meta' => 'boolean', |
| 909 | 975 | 'minify_output' => 'boolean', |
| 910 | - 'cache_duration' => 'integer' | |
| 976 | + 'cache_duration' => 'integer', | |
| 977 | + 'rich_snippets_optimization' => 'boolean', | |
| 978 | + 'knowledge_graph' => 'boolean', | |
| 979 | + 'auto_generate_schema' => 'boolean', | |
| 980 | + 'enable_article_schema' => 'boolean', | |
| 981 | + 'enable_faq_schema' => 'boolean', | |
| 982 | + 'enable_howto_schema' => 'boolean', | |
| 983 | + 'enable_product_schema' => 'boolean', | |
| 984 | + 'enable_local_business' => 'boolean', | |
| 985 | + 'enable_breadcrumbs_schema' => 'boolean', | |
| 911 | 986 | ]; |
| 912 | 987 | |
| 913 | 988 | foreach ($options as $key => $value) { |
| 914 | 989 | $sanitized_key = sanitize_key($key); |