| @@ -108,8 +108,26 @@ | ||
| 108 | 108 | 'HowTo' => [ |
| 109 | 109 | 'required_fields' => ['@type', 'name'], |
| 110 | 110 | 'optional_fields' => ['description', 'totalTime', 'prepTime', 'difficulty', 'estimatedCost', 'supply', 'tool', 'step', 'yield', 'image', 'video'], |
| 111 | 111 | 'max_length' => ['name' => 100, 'description' => 160] |
| 112 | + ], | |
| 113 | + 'BreadcrumbList' => [ | |
| 114 | + 'required_fields' => ['@type', 'itemListElement'], | |
| 115 | + 'optional_fields' => ['name', 'description', 'numberOfItems'], | |
| 116 | + 'max_length' => ['name' => 100, 'description' => 160] | |
| 117 | + ], | |
| 118 | + 'VideoObject' => [ | |
| 119 | + 'required_fields' => ['@type', 'name', 'thumbnailUrl', 'uploadDate'], | |
| 120 | + 'optional_fields' => ['description', 'contentUrl', 'embedUrl', 'duration', 'url'], | |
| 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] | |
| 112 | 130 | ] |
| 113 | 131 | ]; |
| 114 | 132 | |
| 115 | 133 | /** |
| @@ -131,16 +149,8 @@ | ||
| 131 | 149 | */ |
| 132 | 150 | private array $allowed_protocols = ['http', 'https', 'mailto', 'tel']; |
| 133 | 151 | |
| 134 | 152 | /** |
| 135 | - * Rate limiting storage | |
| 136 | - * | |
| 137 | - * @since 1.0.0 | |
| 138 | - * @var array | |
| 139 | - */ | |
| 140 | - private static array $rate_limits = []; | |
| 141 | - | |
| 142 | - /** | |
| 143 | 153 | * Maximum allowed JSON depth to prevent JSON bomb attacks |
| 144 | 154 | * |
| 145 | 155 | * @since 1.0.0 |
| 146 | 156 | * @var int |
| @@ -361,15 +371,29 @@ | ||
| 361 | 371 | $result['errors'][] = 'Invalid @context value. Must be "https://schema.org"'; |
| 362 | 372 | $result['valid'] = false; |
| 363 | 373 | } |
| 364 | 374 | |
| 365 | - // 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. | |
| 366 | 380 | if (!isset($schema_data['@type'])) { |
| 367 | 381 | $result['errors'][] = 'Missing required @type field'; |
| 368 | 382 | $result['valid'] = false; |
| 369 | - } elseif ($schema_data['@type'] !== $schema_type) { | |
| 370 | - $result['errors'][] = "Schema @type '{$schema_data['@type']}' does not match expected type '{$schema_type}'"; | |
| 371 | - $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 | + } | |
| 372 | 396 | } |
| 373 | 397 | |
| 374 | 398 | return $result; |
| 375 | 399 | } |
| @@ -444,19 +468,19 @@ | ||
| 444 | 468 | * @return string Sanitized value |
| 445 | 469 | */ |
| 446 | 470 | private function sanitize_string_value(string $value, string $field_name = ''): string { |
| 447 | 471 | // Handle URLs differently to preserve valid URL structure |
| 448 | - if (in_array($field_name, ['url', 'sameAs', 'logo', 'image', 'mainEntityOfPage'])) { | |
| 472 | + if (in_array($field_name, ['url', 'sameAs', 'logo', 'image', 'mainEntityOfPage'], true)) { | |
| 449 | 473 | return esc_url_raw($value); |
| 450 | 474 | } |
| 451 | 475 | |
| 452 | 476 | // Handle email fields |
| 453 | - if (in_array($field_name, ['email'])) { | |
| 477 | + if (in_array($field_name, ['email'], true)) { | |
| 454 | 478 | return sanitize_email($value); |
| 455 | 479 | } |
| 456 | 480 | |
| 457 | 481 | // Handle description fields that may contain basic HTML |
| 458 | - if (in_array($field_name, ['description', 'text', 'articleBody'])) { | |
| 482 | + if (in_array($field_name, ['description', 'text', 'articleBody'], true)) { | |
| 459 | 483 | // Allow basic HTML but strip dangerous tags |
| 460 | 484 | $allowed_html = [ |
| 461 | 485 | 'p' => [], |
| 462 | 486 | 'br' => [], |
| @@ -470,14 +494,16 @@ | ||
| 470 | 494 | // For other fields, remove all HTML tags |
| 471 | 495 | $value = wp_strip_all_tags($value); |
| 472 | 496 | } |
| 473 | 497 | |
| 474 | - // Sanitize for database storage | |
| 498 | + // Sanitize for database storage. Note: escaping is intentionally NOT done | |
| 499 | + // here. This value is stored and later emitted as JSON-LD inside a | |
| 500 | + // <script type="application/ld+json"> block, where wp_json_encode() is the | |
| 501 | + // correct encoder. Running esc_html() on input would persist HTML entities | |
| 502 | + // (e.g. "Ben & Jerry's" -> "Ben & Jerry's") into the structured | |
| 503 | + // data. Escape at the output boundary, not at storage. | |
| 475 | 504 | $value = sanitize_text_field($value); |
| 476 | 505 | |
| 477 | - // Additional XSS protection for output | |
| 478 | - $value = esc_html($value); | |
| 479 | - | |
| 480 | 506 | return trim($value); |
| 481 | 507 | } |
| 482 | 508 | |
| 483 | 509 | /** |
| @@ -530,11 +556,29 @@ | ||
| 530 | 556 | private function validate_data_formats(array $schema_data, string $schema_type): array { |
| 531 | 557 | $result = ['valid' => true, 'errors' => [], 'warnings' => []]; |
| 532 | 558 | |
| 533 | 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 | + | |
| 534 | 578 | if (is_string($value)) { |
| 535 | 579 | // Validate URLs |
| 536 | - if (in_array($field, ['url', 'sameAs', 'logo', 'image']) && !empty($value)) { | |
| 580 | + if (in_array($field, ['url', 'sameAs', 'logo', 'image'], true) && !empty($value)) { | |
| 537 | 581 | if (!$this->is_valid_url($value)) { |
| 538 | 582 | $result['errors'][] = "Invalid URL format for field: {$field}"; |
| 539 | 583 | $result['valid'] = false; |
| 540 | 584 | } |
| @@ -540,9 +584,9 @@ | ||
| 540 | 584 | } |
| 541 | 585 | } |
| 542 | 586 | |
| 543 | 587 | // Validate email addresses |
| 544 | - if (in_array($field, ['email']) && !empty($value)) { | |
| 588 | + if (in_array($field, ['email'], true) && !empty($value)) { | |
| 545 | 589 | if (!is_email($value)) { |
| 546 | 590 | $result['errors'][] = "Invalid email format for field: {$field}"; |
| 547 | 591 | $result['valid'] = false; |
| 548 | 592 | } |
| @@ -548,9 +592,9 @@ | ||
| 548 | 592 | } |
| 549 | 593 | } |
| 550 | 594 | |
| 551 | 595 | // Validate dates |
| 552 | - if (in_array($field, ['datePublished', 'dateModified']) && !empty($value)) { | |
| 596 | + if (in_array($field, ['datePublished', 'dateModified'], true) && !empty($value)) { | |
| 553 | 597 | if (!$this->is_valid_date($value)) { |
| 554 | 598 | $result['warnings'][] = "Invalid date format for field: {$field}. Use ISO 8601 format."; |
| 555 | 599 | } |
| 556 | 600 | } |
| @@ -602,9 +646,9 @@ | ||
| 602 | 646 | } |
| 603 | 647 | |
| 604 | 648 | // Check allowed protocols |
| 605 | 649 | $parsed = wp_parse_url($url); |
| 606 | - if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], $this->allowed_protocols)) { | |
| 650 | + if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], $this->allowed_protocols, true)) { | |
| 607 | 651 | return false; |
| 608 | 652 | } |
| 609 | 653 | |
| 610 | 654 | return true; |
| @@ -647,34 +691,58 @@ | ||
| 647 | 691 | * @param int $limit Rate limit (requests per hour) |
| 648 | 692 | * @return bool Whether request is allowed |
| 649 | 693 | */ |
| 650 | 694 | public function check_rate_limit(int $user_id, string $action, int $limit = 100): bool { |
| 651 | - $key = "rate_limit_{$user_id}_{$action}"; | |
| 652 | - $current_time = time(); | |
| 653 | - $window_start = $current_time - 3600; // 1 hour window | |
| 695 | + // Persist the window in a transient (object cache / options) so the limit | |
| 696 | + // is enforced ACROSS requests. A per-request static array — as used | |
| 697 | + // previously — always starts empty on a fresh PHP process and therefore | |
| 698 | + // never throttled anything. | |
| 699 | + $key = 'thinkrank_schema_rl_' . $user_id . '_' . sanitize_key($action); | |
| 654 | 700 | |
| 655 | - // Initialize if not exists | |
| 656 | - if (!isset(self::$rate_limits[$key])) { | |
| 657 | - self::$rate_limits[$key] = []; | |
| 658 | - } | |
| 701 | + // Serialize the read-modify-write with a MySQL named lock so concurrent | |
| 702 | + // requests can't each read the same timestamp list, individually pass the | |
| 703 | + // limit check, and overwrite one another — which would let bursts slip | |
| 704 | + // past the configured limit. GET_LOCK is DB-level, so it serializes the | |
| 705 | + // critical section regardless of where the transient is stored. | |
| 706 | + global $wpdb; | |
| 707 | + $lock_name = substr('tr_schema_rl_' . md5($key), 0, 64); | |
| 708 | + $have_lock = ($wpdb instanceof \wpdb) | |
| 709 | + ? (int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, %d)', $lock_name, 3)) === 1 | |
| 710 | + : false; | |
| 659 | 711 | |
| 660 | - // Clean old entries | |
| 661 | - self::$rate_limits[$key] = array_filter( | |
| 662 | - self::$rate_limits[$key], | |
| 663 | - function($timestamp) use ($window_start) { | |
| 664 | - return $timestamp > $window_start; | |
| 712 | + try { | |
| 713 | + $current_time = time(); | |
| 714 | + $window_start = $current_time - HOUR_IN_SECONDS; // 1 hour window | |
| 715 | + | |
| 716 | + $timestamps = get_transient($key); | |
| 717 | + if (!is_array($timestamps)) { | |
| 718 | + $timestamps = []; | |
| 665 | 719 | } |
| 666 | - ); | |
| 667 | 720 | |
| 668 | - // Check if limit exceeded | |
| 669 | - if (count(self::$rate_limits[$key]) >= $limit) { | |
| 670 | - return false; | |
| 671 | - } | |
| 721 | + // Drop entries outside the window. | |
| 722 | + $timestamps = array_values(array_filter( | |
| 723 | + $timestamps, | |
| 724 | + static function ($timestamp) use ($window_start) { | |
| 725 | + return (int) $timestamp > $window_start; | |
| 726 | + } | |
| 727 | + )); | |
| 672 | 728 | |
| 673 | - // Add current request | |
| 674 | - self::$rate_limits[$key][] = $current_time; | |
| 729 | + // Check if limit exceeded. | |
| 730 | + if (count($timestamps) >= $limit) { | |
| 731 | + set_transient($key, $timestamps, HOUR_IN_SECONDS); | |
| 732 | + return false; | |
| 733 | + } | |
| 675 | 734 | |
| 676 | - return true; | |
| 735 | + // Record this request. | |
| 736 | + $timestamps[] = $current_time; | |
| 737 | + set_transient($key, $timestamps, HOUR_IN_SECONDS); | |
| 738 | + | |
| 739 | + return true; | |
| 740 | + } finally { | |
| 741 | + if ($have_lock) { | |
| 742 | + $wpdb->query($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock_name)); | |
| 743 | + } | |
| 744 | + } | |
| 677 | 745 | } |
| 678 | 746 | |
| 679 | 747 | /** |
| 680 | 748 | * Validate user permissions for schema operations |
| @@ -693,14 +761,25 @@ | ||
| 693 | 761 | $result['errors'][] = 'Invalid user or user not logged in'; |
| 694 | 762 | return $result; |
| 695 | 763 | } |
| 696 | 764 | |
| 697 | - // 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 | + | |
| 698 | 777 | switch ($operation) { |
| 699 | 778 | case 'generate': |
| 700 | 779 | case 'validate': |
| 701 | 780 | case 'optimize': |
| 702 | - if (!user_can($user_id, 'edit_posts')) { | |
| 781 | + if (!$has_schema_cap && !user_can($user_id, 'edit_posts')) { | |
| 703 | 782 | $result['errors'][] = 'Insufficient permissions for schema generation/validation'; |
| 704 | 783 | return $result; |
| 705 | 784 | } |
| 706 | 785 | break; |
| @@ -705,9 +784,9 @@ | ||
| 705 | 784 | } |
| 706 | 785 | break; |
| 707 | 786 | |
| 708 | 787 | case 'deploy': |
| 709 | - if (!user_can($user_id, 'publish_posts')) { | |
| 788 | + if (!$has_schema_cap && !user_can($user_id, 'publish_posts')) { | |
| 710 | 789 | $result['errors'][] = 'Insufficient permissions for schema deployment'; |
| 711 | 790 | return $result; |
| 712 | 791 | } |
| 713 | 792 | break; |
| @@ -713,9 +792,9 @@ | ||
| 713 | 792 | break; |
| 714 | 793 | |
| 715 | 794 | case 'manage_settings': |
| 716 | 795 | case 'bulk_operations': |
| 717 | - if (!user_can($user_id, 'manage_options')) { | |
| 796 | + if (!$has_schema_cap && !user_can($user_id, 'manage_options')) { | |
| 718 | 797 | $result['errors'][] = 'Insufficient permissions for schema management'; |
| 719 | 798 | return $result; |
| 720 | 799 | } |
| 721 | 800 | break; |
| @@ -780,10 +859,14 @@ | ||
| 780 | 859 | $result['errors'][] = "Invalid context ID: {$context_id}"; |
| 781 | 860 | return $result; |
| 782 | 861 | } |
| 783 | 862 | |
| 784 | - // SECURITY: Check context ownership | |
| 785 | - if ($user_id && !$this->validate_context_ownership($post, $user_id)) { | |
| 863 | + // SECURITY: Check context ownership. | |
| 864 | + // Fails closed on a missing user — a security helper that waves the | |
| 865 | + // check through when it cannot identify the caller is the wrong way | |
| 866 | + // round. Every caller passes a real ID, so this only tightens an | |
| 867 | + // unreachable path. | |
| 868 | + if (!$user_id || !$this->validate_context_ownership($post, $user_id)) { | |
| 786 | 869 | $result['errors'][] = "Access denied: You don't have permission to modify this {$context_type}"; |
| 787 | 870 | return $result; |
| 788 | 871 | } |
| 789 | 872 | } else { |
| @@ -788,10 +871,18 @@ | ||
| 788 | 871 | } |
| 789 | 872 | } else { |
| 790 | 873 | $context_id = null; // Site context doesn't use ID |
| 791 | 874 | |
| 792 | - // SECURITY: Check site-level permissions for site context | |
| 793 | - if ($user_id && !current_user_can('manage_options')) { | |
| 875 | + // SECURITY: Check site-level permissions for site context. | |
| 876 | + // user_can($user_id, …) rather than current_user_can() so this | |
| 877 | + // agrees with the rest of the validator outside a REST request, | |
| 878 | + // where the current user and $user_id can differ (cron, CLI). | |
| 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'))) { | |
| 794 | 885 | $result['errors'][] = 'Access denied: You need administrator privileges for site-level schema operations'; |
| 795 | 886 | return $result; |
| 796 | 887 | } |
| 797 | 888 | } |
| @@ -814,25 +905,23 @@ | ||
| 814 | 905 | * @param int $user_id User ID |
| 815 | 906 | * @return bool Whether user has permission |
| 816 | 907 | */ |
| 817 | 908 | private function validate_context_ownership(\WP_Post $post, int $user_id): bool { |
| 818 | - // Check if user can edit this specific post | |
| 819 | - if (current_user_can('edit_post', $post->ID)) { | |
| 820 | - return true; | |
| 821 | - } | |
| 822 | - | |
| 823 | - // Check if user is the post author | |
| 824 | - if ($post->post_author == $user_id) { | |
| 825 | - return true; | |
| 826 | - } | |
| 827 | - | |
| 828 | - // Check if user has general edit capabilities for this post type | |
| 829 | - $post_type_object = get_post_type_object($post->post_type); | |
| 830 | - if ($post_type_object && current_user_can($post_type_object->cap->edit_posts)) { | |
| 831 | - return true; | |
| 832 | - } | |
| 833 | - | |
| 834 | - return false; | |
| 909 | + // `edit_post` is a meta capability: map_meta_cap() already resolves | |
| 910 | + // authorship, published state, and edit_others_posts for this specific | |
| 911 | + // post. It is the whole check. | |
| 912 | + // | |
| 913 | + // Two fallbacks used to sit under it and between them defeated the | |
| 914 | + // function. One granted access on authorship alone, which hands a | |
| 915 | + // Contributor back a post they lost edit rights to once it published. | |
| 916 | + // The other granted access to anyone holding the post type's *general* | |
| 917 | + // edit_posts capability — a cap every Author and Contributor has, that | |
| 918 | + // says nothing about this post — so ownership validation returned true | |
| 919 | + // for every post on the site (#326). | |
| 920 | + // | |
| 921 | + // user_can() rather than current_user_can() so the method honours the | |
| 922 | + // $user_id it was handed, matching validate_user_permissions(). | |
| 923 | + return user_can($user_id, 'edit_post', $post->ID); | |
| 835 | 924 | } |
| 836 | 925 | |
| 837 | 926 | /** |
| 838 | 927 | * Validate JSON depth to prevent JSON bomb attacks |
| @@ -868,14 +957,33 @@ | ||
| 868 | 957 | * @return array Sanitized options |
| 869 | 958 | */ |
| 870 | 959 | public function sanitize_options(array $options): array { |
| 871 | 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. | |
| 872 | 971 | $allowed_options = [ |
| 873 | - 'deployment_method' => ['json_ld', 'microdata', 'rdfa'], | |
| 874 | - 'validation_level' => ['strict', 'moderate', 'basic'], | |
| 972 | + 'deployment_method' => ['json_ld'], | |
| 973 | + 'validation_level' => ['strict', 'moderate', 'lenient'], | |
| 875 | 974 | 'include_meta' => 'boolean', |
| 876 | 975 | 'minify_output' => 'boolean', |
| 877 | - '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', | |
| 878 | 986 | ]; |
| 879 | 987 | |
| 880 | 988 | foreach ($options as $key => $value) { |
| 881 | 989 | $sanitized_key = sanitize_key($key); |