| @@ -22,8 +22,10 @@ | ||
| 22 | 22 | |
| 23 | 23 | use ThinkRank\SEO\Schema_Management_System; |
| 24 | 24 | use ThinkRank\SEO\Schema_Input_Validator; |
| 25 | 25 | use ThinkRank\API\Traits\Rate_Limiter; |
| 26 | +use ThinkRank\API\Traits\Context_Authorization; | |
| 27 | +use ThinkRank\API\Traits\CSRF_Protection; | |
| 26 | 28 | use WP_REST_Controller; |
| 27 | 29 | use WP_REST_Request; |
| 28 | 30 | use WP_REST_Response; |
| 29 | 31 | use WP_Error; |
| @@ -29,8 +31,10 @@ | ||
| 29 | 31 | use WP_Error; |
| 30 | 32 | |
| 31 | 33 | // Load Rate Limiter trait |
| 32 | 34 | require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-rate-limiter.php'; |
| 35 | +require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-context-authorization.php'; | |
| 36 | +require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-csrf-protection.php'; | |
| 33 | 37 | |
| 34 | 38 | /** |
| 35 | 39 | * Schema API Endpoints Class |
| 36 | 40 | * |
| @@ -42,8 +46,12 @@ | ||
| 42 | 46 | */ |
| 43 | 47 | class Schema_Endpoint extends WP_REST_Controller { |
| 44 | 48 | |
| 45 | 49 | use Rate_Limiter; |
| 50 | + use Context_Authorization; | |
| 51 | + // Shared nonce check — this class used to carry a byte-identical private | |
| 52 | + // copy of verify_request_nonce() (#457). | |
| 53 | + use CSRF_Protection; | |
| 46 | 54 | |
| 47 | 55 | /** |
| 48 | 56 | * Maximum number of items a single /bulk request may process synchronously. |
| 49 | 57 | * Larger workloads should be paged or queued rather than run in one request. |
| @@ -163,9 +171,10 @@ | ||
| 163 | 171 | [ |
| 164 | 172 | [ |
| 165 | 173 | 'methods' => 'GET', |
| 166 | 174 | 'callback' => [$this, 'get_deployed_schemas'], |
| 167 | - 'permission_callback' => [$this, 'check_read_permissions'] | |
| 175 | + 'permission_callback' => [$this, 'check_read_permissions'], | |
| 176 | + 'args' => $this->get_context_route_args() | |
| 168 | 177 | ] |
| 169 | 178 | ] |
| 170 | 179 | ); |
| 171 | 180 | |
| @@ -268,9 +277,10 @@ | ||
| 268 | 277 | [ |
| 269 | 278 | [ |
| 270 | 279 | 'methods' => 'GET', |
| 271 | 280 | 'callback' => [$this, 'get_settings'], |
| 272 | - 'permission_callback' => [$this, 'check_read_permissions'] | |
| 281 | + 'permission_callback' => [$this, 'check_read_permissions'], | |
| 282 | + 'args' => $this->get_context_route_args() | |
| 273 | 283 | ], |
| 274 | 284 | [ |
| 275 | 285 | 'methods' => 'POST', |
| 276 | 286 | 'callback' => [$this, 'save_settings'], |
| @@ -325,9 +335,9 @@ | ||
| 325 | 335 | // resolves to a private, loopback, link-local, or otherwise reserved |
| 326 | 336 | // IP range — including the link-local 169.254.0.0/16 (cloud metadata, |
| 327 | 337 | // e.g. 169.254.169.254) and 100.64.0.0/10 (CGNAT) ranges that |
| 328 | 338 | // wp_http_validate_url() does NOT block — re-validated on every |
| 329 | - // redirect hop. See fetch_import_url() / reject_disallowed_fetch_host(). | |
| 339 | + // redirect hop. See fetch_import_url() / \ThinkRank\Core\Url_Safety. | |
| 330 | 340 | $response = $this->fetch_import_url($url); |
| 331 | 341 | |
| 332 | 342 | if (is_wp_error($response)) { |
| 333 | 343 | // Preserve the SSRF/redirect block responses (they already carry a |
| @@ -383,11 +393,20 @@ | ||
| 383 | 393 | $json = trim($script->nodeValue); |
| 384 | 394 | $data = json_decode($json, true); |
| 385 | 395 | |
| 386 | 396 | if (json_last_error() === JSON_ERROR_NONE && !empty($data)) { |
| 387 | - // Strictly set @context to https://schema.org | |
| 388 | - $data['@context'] = 'https://schema.org'; | |
| 389 | - $found_schemas[] = $data; | |
| 397 | + // A script block may hold a single entity, a bare list | |
| 398 | + // of entities, or an object wrapping @graph. Treating | |
| 399 | + // every block as one flat object collapsed lists into | |
| 400 | + // numeric keys and never opened @graph — the shape Yoast | |
| 401 | + // and Rank Math emit — so the import produced entries | |
| 402 | + // with no top-level @type that deploy silently dropped | |
| 403 | + // (#467). | |
| 404 | + foreach ($this->extract_schema_entities($data) as $entity) { | |
| 405 | + // Strictly set @context to https://schema.org | |
| 406 | + $entity['@context'] = 'https://schema.org'; | |
| 407 | + $found_schemas[] = $entity; | |
| 408 | + } | |
| 390 | 409 | } |
| 391 | 410 | } |
| 392 | 411 | } |
| 393 | 412 | |
| @@ -415,180 +434,106 @@ | ||
| 415 | 434 | } |
| 416 | 435 | } |
| 417 | 436 | |
| 418 | 437 | /** |
| 419 | - * Fetch a remote URL for schema import, re-validating the host against the | |
| 420 | - * SSRF block list on every redirect hop. | |
| 438 | + * Sanitize schema form data of arbitrary depth. | |
| 421 | 439 | * |
| 422 | - * wp_safe_remote_get() re-validates redirect targets with | |
| 423 | - * wp_http_validate_url(), which shares the link-local/CGNAT blind spot, so | |
| 424 | - * redirects are followed manually (redirection => 0) and each hop is checked | |
| 425 | - * with {@see self::reject_disallowed_fetch_host()}. | |
| 440 | + * The metabox forms post nested structures — `faq_questions` is a list of | |
| 441 | + * `{question, answer}` objects and `howto_steps` a list of `{name, text}` | |
| 442 | + * objects. A flat `array_map('sanitize_text_field', $value)` handed those | |
| 443 | + * inner arrays to a string sanitizer, which returns '', so every question | |
| 444 | + * and step was blanked before the builder saw it and FAQPage generated with | |
| 445 | + * an empty `mainEntity` (failing its own required-property validation). | |
| 446 | + * Recursing keeps the shape and still sanitizes every scalar leaf. | |
| 426 | 447 | * |
| 427 | - * @param string $url URL to fetch. | |
| 428 | - * @return array|\WP_Error Response array on success, WP_Error otherwise. | |
| 448 | + * @since 2.0.2 | |
| 449 | + * | |
| 450 | + * @param array $data Raw form data. | |
| 451 | + * @return array Sanitized form data with structure preserved. | |
| 429 | 452 | */ |
| 430 | - private function fetch_import_url(string $url) { | |
| 431 | - $max_redirects = 5; | |
| 453 | + private function sanitize_schema_form_data(array $data): array { | |
| 454 | + $sanitized = []; | |
| 432 | 455 | |
| 433 | - for ($hop = 0; $hop <= $max_redirects; $hop++) { | |
| 434 | - if (!wp_http_validate_url($url)) { | |
| 435 | - return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]); | |
| 436 | - } | |
| 456 | + foreach ($data as $key => $value) { | |
| 457 | + $clean_key = is_int($key) ? $key : sanitize_key($key); | |
| 437 | 458 | |
| 438 | - $host_check = $this->reject_disallowed_fetch_host($url); | |
| 439 | - if (is_wp_error($host_check)) { | |
| 440 | - return $host_check; | |
| 459 | + if (is_array($value)) { | |
| 460 | + $sanitized[$clean_key] = $this->sanitize_schema_form_data($value); | |
| 461 | + } elseif (is_bool($value)) { | |
| 462 | + $sanitized[$clean_key] = $value; | |
| 463 | + } elseif (is_string($value)) { | |
| 464 | + $sanitized[$clean_key] = sanitize_text_field($value); | |
| 465 | + } elseif (is_numeric($value)) { | |
| 466 | + $sanitized[$clean_key] = floatval($value); | |
| 441 | 467 | } |
| 442 | - | |
| 443 | - $response = wp_safe_remote_get($url, [ | |
| 444 | - 'timeout' => 15, | |
| 445 | - 'redirection' => 0, // Follow manually so every hop is re-validated. | |
| 446 | - 'user-agent' => 'ThinkRank/1.0.0 (WordPress Schema Plugin)', | |
| 447 | - ]); | |
| 448 | - | |
| 449 | - if (is_wp_error($response)) { | |
| 450 | - return $response; | |
| 451 | - } | |
| 452 | - | |
| 453 | - $code = (int) wp_remote_retrieve_response_code($response); | |
| 454 | - if ($code >= 300 && $code < 400) { | |
| 455 | - $location = trim((string) wp_remote_retrieve_header($response, 'location')); | |
| 456 | - if ('' === $location) { | |
| 457 | - return $response; // Redirect without a target — treat as final. | |
| 458 | - } | |
| 459 | - // Resolve a relative Location against the current URL. | |
| 460 | - $url = (string) \WP_Http::make_absolute_url($location, $url); | |
| 461 | - if ('' === $url) { | |
| 462 | - return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]); | |
| 463 | - } | |
| 464 | - continue; | |
| 465 | - } | |
| 466 | - | |
| 467 | - return $response; | |
| 468 | 468 | } |
| 469 | 469 | |
| 470 | - return new WP_Error('too_many_redirects', 'The URL redirected too many times.', ['status' => 400]); | |
| 470 | + return $sanitized; | |
| 471 | 471 | } |
| 472 | 472 | |
| 473 | 473 | /** |
| 474 | - * Reject URLs whose host resolves to a private, loopback, link-local, or | |
| 475 | - * otherwise reserved IP range. | |
| 474 | + * Flatten one decoded JSON-LD script block into individual entities. | |
| 476 | 475 | * |
| 477 | - * WordPress's wp_http_validate_url() blocks loopback and RFC1918 ranges but | |
| 478 | - * NOT the link-local 169.254.0.0/16 (cloud metadata, e.g. 169.254.169.254) | |
| 479 | - * or 100.64.0.0/10 (CGNAT) ranges. FILTER_FLAG_NO_RES_RANGE covers those | |
| 480 | - * reserved ranges, and FILTER_FLAG_NO_PRIV_RANGE covers the private ranges. | |
| 476 | + * JSON-LD allows a script tag to carry a single object, an array of objects, | |
| 477 | + * or an object whose `@graph` holds the entities. Mirrors the Pro file | |
| 478 | + * importer's extract_schemas() so both paths agree (#467). | |
| 481 | 479 | * |
| 482 | - * @param string $url URL to check. | |
| 483 | - * @return true|\WP_Error True when every resolved address is public. | |
| 480 | + * @since 1.16.0 | |
| 481 | + * | |
| 482 | + * @param array $decoded Decoded JSON-LD. | |
| 483 | + * @return array<int,array> One entry per entity. | |
| 484 | 484 | */ |
| 485 | - private function reject_disallowed_fetch_host(string $url) { | |
| 486 | - $host = wp_parse_url($url, PHP_URL_HOST); | |
| 487 | - if (empty($host)) { | |
| 488 | - return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]); | |
| 489 | - } | |
| 485 | + private function extract_schema_entities(array $decoded): array { | |
| 486 | + // Object wrapping @graph — the shape Yoast and Rank Math emit. | |
| 487 | + if (!empty($decoded['@graph']) && is_array($decoded['@graph'])) { | |
| 488 | + $context = $decoded['@context'] ?? null; | |
| 489 | + $entities = []; | |
| 490 | 490 | |
| 491 | - // Strip IPv6 literal brackets, if present. | |
| 492 | - $host = trim($host, '[]'); | |
| 493 | - | |
| 494 | - $ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : $this->resolve_host_ips($host); | |
| 495 | - if (empty($ips)) { | |
| 496 | - return new WP_Error('invalid_url', 'The URL host could not be resolved.', ['status' => 400]); | |
| 497 | - } | |
| 498 | - | |
| 499 | - foreach ($ips as $ip) { | |
| 500 | - if (!$this->is_public_ip($ip)) { | |
| 501 | - return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]); | |
| 491 | + foreach ($decoded['@graph'] as $entity) { | |
| 492 | + if (!is_array($entity) || empty($entity)) { | |
| 493 | + continue; | |
| 494 | + } | |
| 495 | + // Carry the outer @context onto entities that lack their own. | |
| 496 | + if (null !== $context && !isset($entity['@context'])) { | |
| 497 | + $entity['@context'] = $context; | |
| 498 | + } | |
| 499 | + $entities[] = $entity; | |
| 502 | 500 | } |
| 503 | - } | |
| 504 | 501 | |
| 505 | - return true; | |
| 506 | - } | |
| 507 | - | |
| 508 | - /** | |
| 509 | - * Whether an IP address is a routable public address. | |
| 510 | - * | |
| 511 | - * Combines PHP's private/reserved-range filters with explicit blocks for | |
| 512 | - * reserved IPv4 ranges PHP's FILTER_FLAG_NO_RES_RANGE does not cover — most | |
| 513 | - * importantly 100.64.0.0/10 (CGNAT), which some clouds use for metadata. | |
| 514 | - * | |
| 515 | - * @param string $ip IP address (v4 or v6). | |
| 516 | - * @return bool | |
| 517 | - */ | |
| 518 | - private function is_public_ip(string $ip): bool { | |
| 519 | - if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { | |
| 520 | - return false; | |
| 502 | + return $entities; | |
| 521 | 503 | } |
| 522 | 504 | |
| 523 | - // Reserved IPv4 blocks PHP's NO_RES_RANGE misses. | |
| 524 | - $extra_blocks = [ | |
| 525 | - '100.64.0.0/10', // Shared address space / CGNAT (RFC 6598). | |
| 526 | - '192.0.0.0/24', // IETF protocol assignments (RFC 6890). | |
| 527 | - '198.18.0.0/15', // Benchmarking (RFC 2544). | |
| 528 | - '192.88.99.0/24', // 6to4 relay anycast (RFC 7526). | |
| 529 | - ]; | |
| 530 | - foreach ($extra_blocks as $cidr) { | |
| 531 | - if ($this->ipv4_in_cidr($ip, $cidr)) { | |
| 532 | - return false; | |
| 533 | - } | |
| 505 | + // Bare list of entities: [{...}, {...}] | |
| 506 | + if (isset($decoded[0]) && is_array($decoded[0])) { | |
| 507 | + return array_values(array_filter($decoded, static function ($entity) { | |
| 508 | + return is_array($entity) && !empty($entity); | |
| 509 | + })); | |
| 534 | 510 | } |
| 535 | 511 | |
| 536 | - return true; | |
| 512 | + // Single entity. | |
| 513 | + return [$decoded]; | |
| 537 | 514 | } |
| 538 | 515 | |
| 539 | 516 | /** |
| 540 | - * Whether an IPv4 address falls within a CIDR block. | |
| 517 | + * Fetch a remote URL for schema import. | |
| 541 | 518 | * |
| 542 | - * @param string $ip IPv4 address. | |
| 543 | - * @param string $cidr CIDR block (e.g. 100.64.0.0/10). | |
| 544 | - * @return bool | |
| 545 | - */ | |
| 546 | - private function ipv4_in_cidr(string $ip, string $cidr): bool { | |
| 547 | - if (strpos($ip, ':') !== false) { | |
| 548 | - return false; // IPv6 is not covered by these IPv4 blocks. | |
| 549 | - } | |
| 550 | - | |
| 551 | - [$subnet, $bits] = array_pad(explode('/', $cidr, 2), 2, '32'); | |
| 552 | - $ip_long = ip2long($ip); | |
| 553 | - $subnet_long = ip2long($subnet); | |
| 554 | - if (false === $ip_long || false === $subnet_long) { | |
| 555 | - return false; | |
| 556 | - } | |
| 557 | - | |
| 558 | - $mask = -1 << (32 - (int) $bits); | |
| 559 | - return ($ip_long & $mask) === ($subnet_long & $mask); | |
| 560 | - } | |
| 561 | - | |
| 562 | - /** | |
| 563 | - * Resolve a hostname to its IPv4 + IPv6 addresses. | |
| 519 | + * Delegates to the shared SSRF guard, which follows redirects manually and | |
| 520 | + * re-validates the resolved host against the block list on every hop — | |
| 521 | + * wp_safe_remote_get()'s own redirect validation goes through | |
| 522 | + * wp_http_validate_url(), which shares the link-local/CGNAT blind spot. | |
| 564 | 523 | * |
| 565 | - * @param string $host Hostname. | |
| 566 | - * @return string[] Resolved IP addresses (may be empty). | |
| 524 | + * @param string $url URL to fetch. | |
| 525 | + * @return array|\WP_Error Response array on success, WP_Error otherwise. | |
| 567 | 526 | */ |
| 568 | - private function resolve_host_ips(string $host): array { | |
| 569 | - $ips = []; | |
| 570 | - | |
| 571 | - $records = @dns_get_record($host, DNS_A + DNS_AAAA); | |
| 572 | - if (is_array($records)) { | |
| 573 | - foreach ($records as $record) { | |
| 574 | - if (!empty($record['ip'])) { | |
| 575 | - $ips[] = $record['ip']; // A record. | |
| 576 | - } elseif (!empty($record['ipv6'])) { | |
| 577 | - $ips[] = $record['ipv6']; // AAAA record. | |
| 578 | - } | |
| 579 | - } | |
| 580 | - } | |
| 581 | - | |
| 582 | - // Fallback where dns_get_record is unavailable or returns nothing. | |
| 583 | - if (empty($ips)) { | |
| 584 | - $resolved = gethostbyname($host); | |
| 585 | - if ($resolved && $resolved !== $host) { | |
| 586 | - $ips[] = $resolved; | |
| 587 | - } | |
| 588 | - } | |
| 589 | - | |
| 590 | - return $ips; | |
| 527 | + private function fetch_import_url(string $url) { | |
| 528 | + return \ThinkRank\Core\Url_Safety::safe_remote_get($url, [ | |
| 529 | + 'timeout' => 15, | |
| 530 | + 'user-agent' => 'ThinkRank/1.0.0 (WordPress Schema Plugin)', | |
| 531 | + // Without a cap the whole body is buffered into memory and then | |
| 532 | + // handed to DOMDocument at roughly twice the size, so a hostile or | |
| 533 | + // simply enormous page could exhaust the request (#473). | |
| 534 | + 'limit_response_size' => 2 * MB_IN_BYTES, | |
| 535 | + ]); | |
| 591 | 536 | } |
| 592 | 537 | |
| 593 | 538 | /** |
| 594 | 539 | * Generate schema markup |
| @@ -683,23 +628,10 @@ | ||
| 683 | 628 | |
| 684 | 629 | // SECURITY: Sanitize schema_form_data if provided |
| 685 | 630 | $schema_form_data = $request->get_param('schema_form_data'); |
| 686 | 631 | if ($schema_form_data && is_array($schema_form_data)) { |
| 687 | - // Sanitize all form fields | |
| 688 | - $sanitized_form_data = []; | |
| 689 | - foreach ($schema_form_data as $key => $value) { | |
| 690 | - if (is_string($value)) { | |
| 691 | - $sanitized_form_data[sanitize_key($key)] = sanitize_text_field($value); | |
| 692 | - } elseif (is_array($value)) { | |
| 693 | - // Handle array values (like features, steps, etc.) | |
| 694 | - $sanitized_form_data[sanitize_key($key)] = array_map('sanitize_text_field', $value); | |
| 695 | - } elseif (is_numeric($value)) { | |
| 696 | - $sanitized_form_data[sanitize_key($key)] = floatval($value); | |
| 697 | - } | |
| 698 | - } | |
| 699 | - | |
| 700 | 632 | // Add schema_form_data to options so schema manager can use it |
| 701 | - $options['schema_form_data'] = $sanitized_form_data; | |
| 633 | + $options['schema_form_data'] = $this->sanitize_schema_form_data($schema_form_data); | |
| 702 | 634 | } |
| 703 | 635 | |
| 704 | 636 | // Generate schema markup with sanitized inputs |
| 705 | 637 | $generation_results = $this->schema_manager->generate_schema_markup( |
| @@ -861,8 +793,9 @@ | ||
| 861 | 793 | } |
| 862 | 794 | |
| 863 | 795 | // SECURITY: Validate each schema in the data |
| 864 | 796 | $sanitized_schema_data = []; |
| 797 | + $skipped_schemas = []; | |
| 865 | 798 | foreach ($schema_data as $schema_key => $schema_content) { |
| 866 | 799 | $schema_key = sanitize_text_field($schema_key); |
| 867 | 800 | |
| 868 | 801 | if (!is_array($schema_content)) { |
| @@ -873,11 +806,22 @@ | ||
| 873 | 806 | ); |
| 874 | 807 | } |
| 875 | 808 | |
| 876 | 809 | // Ensure schema has required structure fields before validation |
| 877 | - // Use @type from schema content if available, otherwise fall back to key | |
| 878 | - $schema_type = isset($schema_content['@type']) ? sanitize_text_field($schema_content['@type']) : $schema_key; | |
| 879 | - | |
| 810 | + // Use @type from schema content if available, otherwise fall back to key. | |
| 811 | + // `@type` may legitimately be an array ("@type": ["Product","Offer"]); | |
| 812 | + // sanitize_text_field() on an array yields '', which then failed the | |
| 813 | + // whitelist lookup with "Invalid schema type:" (#468). Resolve the | |
| 814 | + // primary type for lookup and leave the original value in the payload. | |
| 815 | + if (isset($schema_content['@type'])) { | |
| 816 | + $raw_type = $schema_content['@type']; | |
| 817 | + $schema_type = is_array($raw_type) | |
| 818 | + ? sanitize_text_field((string) reset($raw_type)) | |
| 819 | + : sanitize_text_field((string) $raw_type); | |
| 820 | + } else { | |
| 821 | + $schema_type = $schema_key; | |
| 822 | + } | |
| 823 | + | |
| 880 | 824 | if (!isset($schema_content['@type'])) { |
| 881 | 825 | $schema_content['@type'] = $schema_type; |
| 882 | 826 | } |
| 883 | 827 | if (!isset($schema_content['@context'])) { |
| @@ -883,20 +827,21 @@ | ||
| 883 | 827 | if (!isset($schema_content['@context'])) { |
| 884 | 828 | $schema_content['@context'] = 'https://schema.org'; |
| 885 | 829 | } |
| 886 | 830 | |
| 887 | - // Validate using the actual schema type, not the key | |
| 831 | + // Validate using the actual schema type, not the key. | |
| 832 | + // A failure skips this entry instead of aborting the batch: the | |
| 833 | + // UI sends every schema in one payload, so one unsupported type | |
| 834 | + // used to block the valid entries alongside it (#468). | |
| 888 | 835 | $input_validation = $this->input_validator->validate_schema_data($schema_content, $schema_type); |
| 836 | + | |
| 889 | 837 | if (!$input_validation['valid']) { |
| 890 | - return new WP_Error( | |
| 891 | - 'schema_validation_failed', | |
| 892 | - "Schema validation failed for {$schema_type}: " . implode(', ', $input_validation['errors']), | |
| 893 | - [ | |
| 894 | - 'status' => 400, | |
| 895 | - 'schema_type' => $schema_type, | |
| 896 | - 'validation_errors' => $input_validation['errors'] | |
| 897 | - ] | |
| 898 | - ); | |
| 838 | + $skipped_schemas[] = [ | |
| 839 | + 'key' => $schema_key, | |
| 840 | + 'type' => $schema_type, | |
| 841 | + 'errors' => $input_validation['errors'], | |
| 842 | + ]; | |
| 843 | + continue; | |
| 899 | 844 | } |
| 900 | 845 | |
| 901 | 846 | // Store using the key (which may be unique like "Article-1") |
| 902 | 847 | $sanitized_schema_data[$schema_key] = $input_validation['sanitized_data']; |
| @@ -901,11 +846,33 @@ | ||
| 901 | 846 | // Store using the key (which may be unique like "Article-1") |
| 902 | 847 | $sanitized_schema_data[$schema_key] = $input_validation['sanitized_data']; |
| 903 | 848 | } |
| 904 | 849 | |
| 850 | + // Every entry failed — that is a request-level error worth a 400, | |
| 851 | + // since there is nothing to deploy. | |
| 852 | + if (empty($sanitized_schema_data) && !empty($skipped_schemas)) { | |
| 853 | + return new WP_Error( | |
| 854 | + 'schema_validation_failed', | |
| 855 | + sprintf( | |
| 856 | + /* translators: %s: comma-separated list of schema types. */ | |
| 857 | + __('No schema could be deployed. Failed types: %s', 'thinkrank'), | |
| 858 | + implode(', ', wp_list_pluck($skipped_schemas, 'type')) | |
| 859 | + ), | |
| 860 | + [ | |
| 861 | + 'status' => 400, | |
| 862 | + 'skipped' => $skipped_schemas, | |
| 863 | + ] | |
| 864 | + ); | |
| 865 | + } | |
| 866 | + | |
| 905 | 867 | // SECURITY: Sanitize options |
| 906 | 868 | $options = $this->input_validator->sanitize_options($request->get_param('options') ?? []); |
| 907 | 869 | |
| 870 | + // This route is the user pressing Deploy, so the payload is the full | |
| 871 | + // intended set for the context — types missing from it were removed | |
| 872 | + // deliberately and must come off the page (#464). | |
| 873 | + $options['authoritative'] = true; | |
| 874 | + | |
| 908 | 875 | // Deploy schema markup with sanitized data |
| 909 | 876 | $deployment_results = $this->schema_manager->deploy_schema_markup( |
| 910 | 877 | $context_type, |
| 911 | 878 | $context_id, |
| @@ -912,14 +879,28 @@ | ||
| 912 | 879 | $sanitized_schema_data, |
| 913 | 880 | $options |
| 914 | 881 | ); |
| 915 | 882 | |
| 916 | - return new WP_REST_Response([ | |
| 883 | + $response = [ | |
| 917 | 884 | 'success' => true, |
| 918 | 885 | 'data' => $deployment_results, |
| 919 | 886 | 'message' => 'Schema markup deployed successfully' |
| 920 | - ], 200); | |
| 887 | + ]; | |
| 921 | 888 | |
| 889 | + // Report what was skipped so the UI can say "3 deployed, 1 skipped" | |
| 890 | + // rather than silently dropping entries (#468). | |
| 891 | + if (!empty($skipped_schemas)) { | |
| 892 | + $response['skipped'] = $skipped_schemas; | |
| 893 | + $response['message'] = sprintf( | |
| 894 | + /* translators: 1: number deployed, 2: number skipped. */ | |
| 895 | + __('Deployed %1$d schema(s); skipped %2$d that failed validation.', 'thinkrank'), | |
| 896 | + count($sanitized_schema_data), | |
| 897 | + count($skipped_schemas) | |
| 898 | + ); | |
| 899 | + } | |
| 900 | + | |
| 901 | + return new WP_REST_Response($response, 200); | |
| 902 | + | |
| 922 | 903 | } catch (\Exception $e) { |
| 923 | 904 | return new WP_Error( |
| 924 | 905 | 'deployment_failed', |
| 925 | 906 | 'Schema deployment failed: ' . $e->getMessage(), |
| @@ -1057,17 +1038,17 @@ | ||
| 1057 | 1038 | * @return WP_REST_Response|WP_Error Response object or error |
| 1058 | 1039 | */ |
| 1059 | 1040 | public function get_deployed_schemas(WP_REST_Request $request) { |
| 1060 | 1041 | try { |
| 1061 | - $context_type = $request->get_param('context_type') ?? 'site'; | |
| 1062 | - $context_id = $request->get_param('context_id'); | |
| 1063 | - | |
| 1064 | - // Convert context_id to int if it's a valid numeric string, otherwise null | |
| 1065 | - if ($context_id !== null && is_numeric($context_id)) { | |
| 1066 | - $context_id = (int) $context_id; | |
| 1067 | - } else { | |
| 1068 | - $context_id = null; | |
| 1042 | + // SECURITY: this route reads the schema deployed against a specific | |
| 1043 | + // object. The thinkrank_schema capability authorises the section, not | |
| 1044 | + // every post on the site, so the object itself has to be authorised | |
| 1045 | + // before the read (#385). | |
| 1046 | + $context = $this->resolve_request_context($request); | |
| 1047 | + if (is_wp_error($context)) { | |
| 1048 | + return $context; | |
| 1069 | 1049 | } |
| 1050 | + [$context_type, $context_id] = $context; | |
| 1070 | 1051 | |
| 1071 | 1052 | $deployed_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id); |
| 1072 | 1053 | |
| 1073 | 1054 | return new WP_REST_Response([ |
| @@ -1097,15 +1078,13 @@ | ||
| 1097 | 1078 | try { |
| 1098 | 1079 | $context_type = $request->get_param('context_type'); |
| 1099 | 1080 | $context_id = (int) $request->get_param('context_id'); |
| 1100 | 1081 | |
| 1101 | - // Validate context | |
| 1102 | - if (!$this->validate_context($context_type, $context_id)) { | |
| 1103 | - return new WP_Error( | |
| 1104 | - 'invalid_context', | |
| 1105 | - 'Invalid context type or ID provided', | |
| 1106 | - ['status' => 400] | |
| 1107 | - ); | |
| 1082 | + // Validate context and the caller's access to it. Returns true or a | |
| 1083 | + // WP_Error carrying the right status (400 shape, 403 authorization). | |
| 1084 | + $context_validation = $this->validate_context($context_type, $context_id); | |
| 1085 | + if (is_wp_error($context_validation)) { | |
| 1086 | + return $context_validation; | |
| 1108 | 1087 | } |
| 1109 | 1088 | |
| 1110 | 1089 | // Get schema output data |
| 1111 | 1090 | $schema_data = $this->schema_manager->get_output_data($context_type, $context_id); |
| @@ -1159,11 +1138,38 @@ | ||
| 1159 | 1138 | ['status' => 400] |
| 1160 | 1139 | ); |
| 1161 | 1140 | } |
| 1162 | 1141 | |
| 1163 | - // Optimize rich snippets | |
| 1142 | + // SECURITY: Validate and sanitize schema data using input validator, | |
| 1143 | + // the same way generate/validate/deploy do — this route must not be | |
| 1144 | + // the one path that hands a raw client blob to the schema manager. | |
| 1145 | + if (!is_array($schema_data)) { | |
| 1146 | + return new WP_Error( | |
| 1147 | + 'invalid_schema_data', | |
| 1148 | + 'Schema data must be an array/object', | |
| 1149 | + ['status' => 400] | |
| 1150 | + ); | |
| 1151 | + } | |
| 1152 | + | |
| 1153 | + $input_validation = $this->input_validator->validate_schema_data($schema_data, $schema_type); | |
| 1154 | + if (!$input_validation['valid']) { | |
| 1155 | + return new WP_Error( | |
| 1156 | + 'schema_validation_failed', | |
| 1157 | + 'Schema data validation failed: ' . implode(', ', $input_validation['errors']), | |
| 1158 | + [ | |
| 1159 | + 'status' => 400, | |
| 1160 | + 'validation_errors' => $input_validation['errors'], | |
| 1161 | + 'validation_warnings' => $input_validation['warnings'] | |
| 1162 | + ] | |
| 1163 | + ); | |
| 1164 | + } | |
| 1165 | + | |
| 1166 | + // SECURITY: Sanitize options | |
| 1167 | + $options = $this->input_validator->sanitize_options($options); | |
| 1168 | + | |
| 1169 | + // Optimize rich snippets with the sanitized data | |
| 1164 | 1170 | $optimization_results = $this->schema_manager->optimize_rich_snippets( |
| 1165 | - $schema_data, | |
| 1171 | + $input_validation['sanitized_data'], | |
| 1166 | 1172 | $schema_type, |
| 1167 | 1173 | $options |
| 1168 | 1174 | ); |
| 1169 | 1175 | |
| @@ -1195,15 +1201,13 @@ | ||
| 1195 | 1201 | $context_type = $request->get_param('context_type'); |
| 1196 | 1202 | $context_id = (int) $request->get_param('context_id'); |
| 1197 | 1203 | $options = $request->get_param('options') ?? []; |
| 1198 | 1204 | |
| 1199 | - // Validate context | |
| 1200 | - if (!$this->validate_context($context_type, $context_id)) { | |
| 1201 | - return new WP_Error( | |
| 1202 | - 'invalid_context', | |
| 1203 | - 'Invalid context type or ID provided', | |
| 1204 | - ['status' => 400] | |
| 1205 | - ); | |
| 1205 | + // Validate context and the caller's access to it. Returns true or a | |
| 1206 | + // WP_Error carrying the right status (400 shape, 403 authorization). | |
| 1207 | + $context_validation = $this->validate_context($context_type, $context_id); | |
| 1208 | + if (is_wp_error($context_validation)) { | |
| 1209 | + return $context_validation; | |
| 1206 | 1210 | } |
| 1207 | 1211 | |
| 1208 | 1212 | // Track schema performance |
| 1209 | 1213 | $performance_data = $this->schema_manager->track_schema_performance( |
| @@ -1273,8 +1277,10 @@ | ||
| 1273 | 1277 | * @since 1.0.0 |
| 1274 | 1278 | * |
| 1275 | 1279 | * @param WP_REST_Request $request Request object |
| 1276 | 1280 | * @return WP_REST_Response|WP_Error Response object or error |
| 1281 | + * | |
| 1282 | + * @throws \Exception On failure. | |
| 1277 | 1283 | */ |
| 1278 | 1284 | public function bulk_operations(WP_REST_Request $request) { |
| 1279 | 1285 | try { |
| 1280 | 1286 | $user_id = get_current_user_id(); |
| @@ -1484,10 +1490,13 @@ | ||
| 1484 | 1490 | * @param WP_REST_Request $request Request object |
| 1485 | 1491 | * @return bool Permission status |
| 1486 | 1492 | */ |
| 1487 | 1493 | public function check_generate_permissions(WP_REST_Request $request): bool { |
| 1488 | - // Check user capability | |
| 1489 | - if (!current_user_can('edit_posts')) { | |
| 1494 | + // Gate on the Role Manager's schema capability, like the read and | |
| 1495 | + // settings routes. Core post caps were both too loose in principle and | |
| 1496 | + // too strict in practice: a role granted schema access but without | |
| 1497 | + // publish_posts could not deploy (#457). | |
| 1498 | + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) { | |
| 1490 | 1499 | return false; |
| 1491 | 1500 | } |
| 1492 | 1501 | |
| 1493 | 1502 | // SECURITY: Verify nonce for CSRF protection |
| @@ -1502,10 +1511,13 @@ | ||
| 1502 | 1511 | * @param WP_REST_Request $request Request object |
| 1503 | 1512 | * @return bool Permission status |
| 1504 | 1513 | */ |
| 1505 | 1514 | public function check_validate_permissions(WP_REST_Request $request): bool { |
| 1506 | - // Check user capability | |
| 1507 | - if (!current_user_can('edit_posts')) { | |
| 1515 | + // Gate on the Role Manager's schema capability, like the read and | |
| 1516 | + // settings routes. Core post caps were both too loose in principle and | |
| 1517 | + // too strict in practice: a role granted schema access but without | |
| 1518 | + // publish_posts could not deploy (#457). | |
| 1519 | + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) { | |
| 1508 | 1520 | return false; |
| 1509 | 1521 | } |
| 1510 | 1522 | |
| 1511 | 1523 | // SECURITY: Verify nonce for CSRF protection |
| @@ -1520,10 +1532,13 @@ | ||
| 1520 | 1532 | * @param WP_REST_Request $request Request object |
| 1521 | 1533 | * @return bool Permission status |
| 1522 | 1534 | */ |
| 1523 | 1535 | public function check_deploy_permissions(WP_REST_Request $request): bool { |
| 1524 | - // Check user capability | |
| 1525 | - if (!current_user_can('publish_posts')) { | |
| 1536 | + // Gate on the Role Manager's schema capability, like the read and | |
| 1537 | + // settings routes. Core post caps were both too loose in principle and | |
| 1538 | + // too strict in practice: a role granted schema access but without | |
| 1539 | + // publish_posts could not deploy (#457). | |
| 1540 | + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) { | |
| 1526 | 1541 | return false; |
| 1527 | 1542 | } |
| 1528 | 1543 | |
| 1529 | 1544 | // SECURITY: Verify nonce for CSRF protection |
| @@ -1552,10 +1567,13 @@ | ||
| 1552 | 1567 | * @param WP_REST_Request $request Request object |
| 1553 | 1568 | * @return bool Permission status |
| 1554 | 1569 | */ |
| 1555 | 1570 | public function check_optimize_permissions(WP_REST_Request $request): bool { |
| 1556 | - // Check user capability | |
| 1557 | - if (!current_user_can('edit_posts')) { | |
| 1571 | + // Gate on the Role Manager's schema capability, like the read and | |
| 1572 | + // settings routes. Core post caps were both too loose in principle and | |
| 1573 | + // too strict in practice: a role granted schema access but without | |
| 1574 | + // publish_posts could not deploy (#457). | |
| 1575 | + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) { | |
| 1558 | 1576 | return false; |
| 1559 | 1577 | } |
| 1560 | 1578 | |
| 1561 | 1579 | // SECURITY: Verify nonce for CSRF protection |
| @@ -1605,61 +1623,12 @@ | ||
| 1605 | 1623 | /** |
| 1606 | 1624 | * Helper methods |
| 1607 | 1625 | */ |
| 1608 | 1626 | |
| 1609 | - /** | |
| 1610 | - * Verify request nonce for CSRF protection | |
| 1611 | - * | |
| 1612 | - * @since 1.0.0 | |
| 1613 | - * | |
| 1614 | - * @param WP_REST_Request $request Request object | |
| 1615 | - * @return bool Whether nonce is valid | |
| 1616 | - */ | |
| 1617 | - private function verify_request_nonce(WP_REST_Request $request): bool { | |
| 1618 | - // Get nonce from header (preferred method for REST API) | |
| 1619 | - $nonce = $request->get_header('X-WP-Nonce'); | |
| 1627 | + // verify_request_nonce() now comes from the shared CSRF_Protection trait | |
| 1628 | + // used by the other endpoints; the local copy was identical (#457). | |
| 1620 | 1629 | |
| 1621 | - // Fallback to parameter if header not present | |
| 1622 | - if (!$nonce) { | |
| 1623 | - $nonce = $request->get_param('_wpnonce'); | |
| 1624 | - } | |
| 1625 | - | |
| 1626 | - // Verify nonce | |
| 1627 | - if (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) { | |
| 1628 | - return false; | |
| 1629 | - } | |
| 1630 | - | |
| 1631 | - return true; | |
| 1632 | - } | |
| 1633 | - | |
| 1634 | 1630 | /** |
| 1635 | - * Validate context type and ID | |
| 1636 | - * | |
| 1637 | - * @since 1.0.0 | |
| 1638 | - * | |
| 1639 | - * @param string $context_type Context type | |
| 1640 | - * @param int|null $context_id Context ID | |
| 1641 | - * @return bool Validation status | |
| 1642 | - */ | |
| 1643 | - private function validate_context(string $context_type, ?int $context_id): bool { | |
| 1644 | - $valid_types = ['site', 'post', 'page', 'product']; | |
| 1645 | - | |
| 1646 | - if (!in_array($context_type, $valid_types, true)) { | |
| 1647 | - return false; | |
| 1648 | - } | |
| 1649 | - | |
| 1650 | - if ($context_type !== 'site' && (!$context_id || $context_id <= 0)) { | |
| 1651 | - return false; | |
| 1652 | - } | |
| 1653 | - | |
| 1654 | - if ($context_id && !get_post($context_id)) { | |
| 1655 | - return false; | |
| 1656 | - } | |
| 1657 | - | |
| 1658 | - return true; | |
| 1659 | - } | |
| 1660 | - | |
| 1661 | - /** | |
| 1662 | 1631 | * Generate schema preview |
| 1663 | 1632 | * |
| 1664 | 1633 | * @since 1.0.0 |
| 1665 | 1634 | * |
| @@ -1834,10 +1803,13 @@ | ||
| 1834 | 1803 | 'minimum' => 1, |
| 1835 | 1804 | 'description' => 'Context ID (not required for site context)' |
| 1836 | 1805 | ], |
| 1837 | 1806 | 'schema_types' => [ |
| 1838 | - 'required' => false, | |
| 1807 | + // generate_schema() rejects a missing or empty value with a 400, | |
| 1808 | + // so the schema has to say so too. | |
| 1809 | + 'required' => true, | |
| 1839 | 1810 | 'type' => 'array', |
| 1811 | + 'minItems' => 1, | |
| 1840 | 1812 | 'items' => [ |
| 1841 | 1813 | 'type' => 'string', |
| 1842 | 1814 | 'enum' => [ |
| 1843 | 1815 | 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle', |
| @@ -1842,9 +1814,9 @@ | ||
| 1842 | 1814 | 'enum' => [ |
| 1843 | 1815 | 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle', |
| 1844 | 1816 | 'ScholarlyArticle', 'Report', 'Product', 'Organization', |
| 1845 | 1817 | 'LocalBusiness', 'Person', 'WebSite', 'FAQPage', |
| 1846 | - 'Event', 'HowTo', 'SoftwareApplication' | |
| 1818 | + 'Event', 'HowTo', 'SoftwareApplication', 'Review', 'VideoObject' | |
| 1847 | 1819 | ] |
| 1848 | 1820 | ], |
| 1849 | 1821 | 'description' => 'Schema types to generate' |
| 1850 | 1822 | ], |
| @@ -1889,9 +1861,9 @@ | ||
| 1889 | 1861 | 'enum' => [ |
| 1890 | 1862 | 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle', |
| 1891 | 1863 | 'ScholarlyArticle', 'Report', 'Product', 'Organization', |
| 1892 | 1864 | 'LocalBusiness', 'Person', 'WebSite', 'WebPage', 'FAQPage', |
| 1893 | - 'SoftwareApplication', 'Event', 'Recipe', 'HowTo' | |
| 1865 | + 'SoftwareApplication', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject' | |
| 1894 | 1866 | ], |
| 1895 | 1867 | 'description' => 'Schema type' |
| 1896 | 1868 | ], |
| 1897 | 1869 | 'options' => [ |
| @@ -1955,9 +1927,9 @@ | ||
| 1955 | 1927 | 'type' => 'string', |
| 1956 | 1928 | 'enum' => [ |
| 1957 | 1929 | 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness', |
| 1958 | 1930 | 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication', |
| 1959 | - 'BreadcrumbList', 'Event', 'Recipe', 'HowTo' | |
| 1931 | + 'BreadcrumbList', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject' | |
| 1960 | 1932 | ], |
| 1961 | 1933 | 'description' => 'Schema type' |
| 1962 | 1934 | ], |
| 1963 | 1935 | 'options' => [ |
| @@ -1987,9 +1959,9 @@ | ||
| 1987 | 1959 | 'type' => 'string', |
| 1988 | 1960 | 'enum' => [ |
| 1989 | 1961 | 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness', |
| 1990 | 1962 | 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication', |
| 1991 | - 'BreadcrumbList', 'Event', 'Recipe', 'HowTo' | |
| 1963 | + 'BreadcrumbList', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject' | |
| 1992 | 1964 | ], |
| 1993 | 1965 | 'description' => 'Schema type' |
| 1994 | 1966 | ] |
| 1995 | 1967 | ]; |
| @@ -2039,10 +2011,15 @@ | ||
| 2039 | 2011 | * @return WP_REST_Response|WP_Error Response object or error |
| 2040 | 2012 | */ |
| 2041 | 2013 | public function get_settings(WP_REST_Request $request) { |
| 2042 | 2014 | try { |
| 2043 | - $context_type = $request->get_param('context_type') ?? 'site'; | |
| 2044 | - $context_id = $request->get_param('context_id') ?? null; | |
| 2015 | + // SECURITY: the settings this returns are per-object. save_settings() | |
| 2016 | + // already authorises the object; the read has to as well (#385). | |
| 2017 | + $context = $this->resolve_request_context($request); | |
| 2018 | + if (is_wp_error($context)) { | |
| 2019 | + return $context; | |
| 2020 | + } | |
| 2021 | + [$context_type, $context_id] = $context; | |
| 2045 | 2022 | |
| 2046 | 2023 | // Get settings from schema manager |
| 2047 | 2024 | $settings = $this->schema_manager->get_settings($context_type, $context_id); |
| 2048 | 2025 | |
| @@ -2107,8 +2084,15 @@ | ||
| 2107 | 2084 | ); |
| 2108 | 2085 | } |
| 2109 | 2086 | $context_type = $context_validation['sanitized_data']['context_type']; |
| 2110 | 2087 | $context_id = $context_validation['sanitized_data']['context_id']; |
| 2088 | + } else { | |
| 2089 | + // Site settings are keyed on a NULL context_id. Passing the | |
| 2090 | + // client's value straight through meant a stray context_id | |
| 2091 | + // wrote a row at an arbitrary id, returned 200, and was never | |
| 2092 | + // read back by anything (#470). validate_context_parameters() | |
| 2093 | + // already normalises this internally for other contexts. | |
| 2094 | + $context_id = null; | |
| 2111 | 2095 | } |
| 2112 | 2096 | |
| 2113 | 2097 | // Drop unrecognized keys so arbitrary client-supplied keys aren't |
| 2114 | 2098 | // persisted as settings rows (storage bloat / settings drift). |
| @@ -2186,14 +2170,32 @@ | ||
| 2186 | 2170 | * @param string $context_type Context type (site/post/page/product). |
| 2187 | 2171 | * @return array Settings limited to known keys. |
| 2188 | 2172 | */ |
| 2189 | 2173 | private function filter_known_setting_keys(array $settings, string $context_type): array { |
| 2190 | - $known = array_keys(\ThinkRank\Config\Schema_Settings_Config::get_default_settings($context_type)); | |
| 2191 | - // Keys stored/consumed by adjacent features that share the settings | |
| 2192 | - // store but aren't part of the schema defaults. | |
| 2193 | - $known = array_merge($known, array_keys(\ThinkRank\Config\Schema_Settings_Config::get_settings_schema($context_type)), [ | |
| 2194 | - 'business_name', 'site_name', 'logo_url', 'performance_tracking', | |
| 2195 | - ]); | |
| 2174 | + // Defer to the manager instead of maintaining a parallel list here. | |
| 2175 | + // The endpoint's own list ignored additional_setting_keys() and | |
| 2176 | + // dynamic_setting_key_patterns() — the mechanism #452 added so new form | |
| 2177 | + // families stop getting dropped — so the two disagreed in both | |
| 2178 | + // directions: the four enable_*_schema toggles and the software_/howto_/ | |
| 2179 | + // product_ families were dropped here but accepted by the manager, while | |
| 2180 | + // deployment_method, site_name and performance_tracking survived here | |
| 2181 | + // only to be dropped one layer down (#470). | |
| 2182 | + $known = []; | |
| 2183 | + | |
| 2184 | + foreach (array_keys($settings) as $key) { | |
| 2185 | + if ($this->schema_manager->accepts_setting_key((string) $key, $context_type)) { | |
| 2186 | + $known[] = (string) $key; | |
| 2187 | + } | |
| 2188 | + } | |
| 2189 | + | |
| 2190 | + /** | |
| 2191 | + * Filter the schema setting keys the REST endpoint will persist. | |
| 2192 | + * | |
| 2193 | + * @since 1.13.0 | |
| 2194 | + * | |
| 2195 | + * @param string[] $known Keys accepted by the schema manager. | |
| 2196 | + * @param string $context_type Context type. | |
| 2197 | + */ | |
| 2196 | 2198 | $known = apply_filters('thinkrank_schema_known_setting_keys', $known, $context_type); |
| 2197 | 2199 | |
| 2198 | 2200 | return array_intersect_key($settings, array_flip($known)); |
| 2199 | 2201 | } |