| @@ -23,8 +23,9 @@ | ||
| 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 | 26 | use ThinkRank\API\Traits\Context_Authorization; |
| 27 | +use ThinkRank\API\Traits\CSRF_Protection; | |
| 27 | 28 | use WP_REST_Controller; |
| 28 | 29 | use WP_REST_Request; |
| 29 | 30 | use WP_REST_Response; |
| 30 | 31 | use WP_Error; |
| @@ -31,8 +32,9 @@ | ||
| 31 | 32 | |
| 32 | 33 | // Load Rate Limiter trait |
| 33 | 34 | require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-rate-limiter.php'; |
| 34 | 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'; | |
| 35 | 37 | |
| 36 | 38 | /** |
| 37 | 39 | * Schema API Endpoints Class |
| 38 | 40 | * |
| @@ -45,8 +47,11 @@ | ||
| 45 | 47 | class Schema_Endpoint extends WP_REST_Controller { |
| 46 | 48 | |
| 47 | 49 | use Rate_Limiter; |
| 48 | 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; | |
| 49 | 54 | |
| 50 | 55 | /** |
| 51 | 56 | * Maximum number of items a single /bulk request may process synchronously. |
| 52 | 57 | * Larger workloads should be paged or queued rather than run in one request. |
| @@ -388,11 +393,20 @@ | ||
| 388 | 393 | $json = trim($script->nodeValue); |
| 389 | 394 | $data = json_decode($json, true); |
| 390 | 395 | |
| 391 | 396 | if (json_last_error() === JSON_ERROR_NONE && !empty($data)) { |
| 392 | - // Strictly set @context to https://schema.org | |
| 393 | - $data['@context'] = 'https://schema.org'; | |
| 394 | - $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 | + } | |
| 395 | 409 | } |
| 396 | 410 | } |
| 397 | 411 | } |
| 398 | 412 | |
| @@ -420,8 +434,87 @@ | ||
| 420 | 434 | } |
| 421 | 435 | } |
| 422 | 436 | |
| 423 | 437 | /** |
| 438 | + * Sanitize schema form data of arbitrary depth. | |
| 439 | + * | |
| 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. | |
| 447 | + * | |
| 448 | + * @since 2.0.2 | |
| 449 | + * | |
| 450 | + * @param array $data Raw form data. | |
| 451 | + * @return array Sanitized form data with structure preserved. | |
| 452 | + */ | |
| 453 | + private function sanitize_schema_form_data(array $data): array { | |
| 454 | + $sanitized = []; | |
| 455 | + | |
| 456 | + foreach ($data as $key => $value) { | |
| 457 | + $clean_key = is_int($key) ? $key : sanitize_key($key); | |
| 458 | + | |
| 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); | |
| 467 | + } | |
| 468 | + } | |
| 469 | + | |
| 470 | + return $sanitized; | |
| 471 | + } | |
| 472 | + | |
| 473 | + /** | |
| 474 | + * Flatten one decoded JSON-LD script block into individual entities. | |
| 475 | + * | |
| 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). | |
| 479 | + * | |
| 480 | + * @since 1.16.0 | |
| 481 | + * | |
| 482 | + * @param array $decoded Decoded JSON-LD. | |
| 483 | + * @return array<int,array> One entry per entity. | |
| 484 | + */ | |
| 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 | + | |
| 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; | |
| 500 | + } | |
| 501 | + | |
| 502 | + return $entities; | |
| 503 | + } | |
| 504 | + | |
| 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 | + })); | |
| 510 | + } | |
| 511 | + | |
| 512 | + // Single entity. | |
| 513 | + return [$decoded]; | |
| 514 | + } | |
| 515 | + | |
| 516 | + /** | |
| 424 | 517 | * Fetch a remote URL for schema import. |
| 425 | 518 | * |
| 426 | 519 | * Delegates to the shared SSRF guard, which follows redirects manually and |
| 427 | 520 | * re-validates the resolved host against the block list on every hop — |
| @@ -434,8 +527,12 @@ | ||
| 434 | 527 | private function fetch_import_url(string $url) { |
| 435 | 528 | return \ThinkRank\Core\Url_Safety::safe_remote_get($url, [ |
| 436 | 529 | 'timeout' => 15, |
| 437 | 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, | |
| 438 | 535 | ]); |
| 439 | 536 | } |
| 440 | 537 | |
| 441 | 538 | /** |
| @@ -531,23 +628,10 @@ | ||
| 531 | 628 | |
| 532 | 629 | // SECURITY: Sanitize schema_form_data if provided |
| 533 | 630 | $schema_form_data = $request->get_param('schema_form_data'); |
| 534 | 631 | if ($schema_form_data && is_array($schema_form_data)) { |
| 535 | - // Sanitize all form fields | |
| 536 | - $sanitized_form_data = []; | |
| 537 | - foreach ($schema_form_data as $key => $value) { | |
| 538 | - if (is_string($value)) { | |
| 539 | - $sanitized_form_data[sanitize_key($key)] = sanitize_text_field($value); | |
| 540 | - } elseif (is_array($value)) { | |
| 541 | - // Handle array values (like features, steps, etc.) | |
| 542 | - $sanitized_form_data[sanitize_key($key)] = array_map('sanitize_text_field', $value); | |
| 543 | - } elseif (is_numeric($value)) { | |
| 544 | - $sanitized_form_data[sanitize_key($key)] = floatval($value); | |
| 545 | - } | |
| 546 | - } | |
| 547 | - | |
| 548 | 632 | // Add schema_form_data to options so schema manager can use it |
| 549 | - $options['schema_form_data'] = $sanitized_form_data; | |
| 633 | + $options['schema_form_data'] = $this->sanitize_schema_form_data($schema_form_data); | |
| 550 | 634 | } |
| 551 | 635 | |
| 552 | 636 | // Generate schema markup with sanitized inputs |
| 553 | 637 | $generation_results = $this->schema_manager->generate_schema_markup( |
| @@ -709,8 +793,9 @@ | ||
| 709 | 793 | } |
| 710 | 794 | |
| 711 | 795 | // SECURITY: Validate each schema in the data |
| 712 | 796 | $sanitized_schema_data = []; |
| 797 | + $skipped_schemas = []; | |
| 713 | 798 | foreach ($schema_data as $schema_key => $schema_content) { |
| 714 | 799 | $schema_key = sanitize_text_field($schema_key); |
| 715 | 800 | |
| 716 | 801 | if (!is_array($schema_content)) { |
| @@ -721,11 +806,22 @@ | ||
| 721 | 806 | ); |
| 722 | 807 | } |
| 723 | 808 | |
| 724 | 809 | // Ensure schema has required structure fields before validation |
| 725 | - // Use @type from schema content if available, otherwise fall back to key | |
| 726 | - $schema_type = isset($schema_content['@type']) ? sanitize_text_field($schema_content['@type']) : $schema_key; | |
| 727 | - | |
| 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 | + | |
| 728 | 824 | if (!isset($schema_content['@type'])) { |
| 729 | 825 | $schema_content['@type'] = $schema_type; |
| 730 | 826 | } |
| 731 | 827 | if (!isset($schema_content['@context'])) { |
| @@ -731,20 +827,21 @@ | ||
| 731 | 827 | if (!isset($schema_content['@context'])) { |
| 732 | 828 | $schema_content['@context'] = 'https://schema.org'; |
| 733 | 829 | } |
| 734 | 830 | |
| 735 | - // 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). | |
| 736 | 835 | $input_validation = $this->input_validator->validate_schema_data($schema_content, $schema_type); |
| 836 | + | |
| 737 | 837 | if (!$input_validation['valid']) { |
| 738 | - return new WP_Error( | |
| 739 | - 'schema_validation_failed', | |
| 740 | - "Schema validation failed for {$schema_type}: " . implode(', ', $input_validation['errors']), | |
| 741 | - [ | |
| 742 | - 'status' => 400, | |
| 743 | - 'schema_type' => $schema_type, | |
| 744 | - 'validation_errors' => $input_validation['errors'] | |
| 745 | - ] | |
| 746 | - ); | |
| 838 | + $skipped_schemas[] = [ | |
| 839 | + 'key' => $schema_key, | |
| 840 | + 'type' => $schema_type, | |
| 841 | + 'errors' => $input_validation['errors'], | |
| 842 | + ]; | |
| 843 | + continue; | |
| 747 | 844 | } |
| 748 | 845 | |
| 749 | 846 | // Store using the key (which may be unique like "Article-1") |
| 750 | 847 | $sanitized_schema_data[$schema_key] = $input_validation['sanitized_data']; |
| @@ -749,11 +846,33 @@ | ||
| 749 | 846 | // Store using the key (which may be unique like "Article-1") |
| 750 | 847 | $sanitized_schema_data[$schema_key] = $input_validation['sanitized_data']; |
| 751 | 848 | } |
| 752 | 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 | + | |
| 753 | 867 | // SECURITY: Sanitize options |
| 754 | 868 | $options = $this->input_validator->sanitize_options($request->get_param('options') ?? []); |
| 755 | 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 | + | |
| 756 | 875 | // Deploy schema markup with sanitized data |
| 757 | 876 | $deployment_results = $this->schema_manager->deploy_schema_markup( |
| 758 | 877 | $context_type, |
| 759 | 878 | $context_id, |
| @@ -760,14 +879,28 @@ | ||
| 760 | 879 | $sanitized_schema_data, |
| 761 | 880 | $options |
| 762 | 881 | ); |
| 763 | 882 | |
| 764 | - return new WP_REST_Response([ | |
| 883 | + $response = [ | |
| 765 | 884 | 'success' => true, |
| 766 | 885 | 'data' => $deployment_results, |
| 767 | 886 | 'message' => 'Schema markup deployed successfully' |
| 768 | - ], 200); | |
| 887 | + ]; | |
| 769 | 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 | + | |
| 770 | 903 | } catch (\Exception $e) { |
| 771 | 904 | return new WP_Error( |
| 772 | 905 | 'deployment_failed', |
| 773 | 906 | 'Schema deployment failed: ' . $e->getMessage(), |
| @@ -1357,10 +1490,13 @@ | ||
| 1357 | 1490 | * @param WP_REST_Request $request Request object |
| 1358 | 1491 | * @return bool Permission status |
| 1359 | 1492 | */ |
| 1360 | 1493 | public function check_generate_permissions(WP_REST_Request $request): bool { |
| 1361 | - // Check user capability | |
| 1362 | - 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')) { | |
| 1363 | 1499 | return false; |
| 1364 | 1500 | } |
| 1365 | 1501 | |
| 1366 | 1502 | // SECURITY: Verify nonce for CSRF protection |
| @@ -1375,10 +1511,13 @@ | ||
| 1375 | 1511 | * @param WP_REST_Request $request Request object |
| 1376 | 1512 | * @return bool Permission status |
| 1377 | 1513 | */ |
| 1378 | 1514 | public function check_validate_permissions(WP_REST_Request $request): bool { |
| 1379 | - // Check user capability | |
| 1380 | - 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')) { | |
| 1381 | 1520 | return false; |
| 1382 | 1521 | } |
| 1383 | 1522 | |
| 1384 | 1523 | // SECURITY: Verify nonce for CSRF protection |
| @@ -1393,10 +1532,13 @@ | ||
| 1393 | 1532 | * @param WP_REST_Request $request Request object |
| 1394 | 1533 | * @return bool Permission status |
| 1395 | 1534 | */ |
| 1396 | 1535 | public function check_deploy_permissions(WP_REST_Request $request): bool { |
| 1397 | - // Check user capability | |
| 1398 | - 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')) { | |
| 1399 | 1541 | return false; |
| 1400 | 1542 | } |
| 1401 | 1543 | |
| 1402 | 1544 | // SECURITY: Verify nonce for CSRF protection |
| @@ -1425,10 +1567,13 @@ | ||
| 1425 | 1567 | * @param WP_REST_Request $request Request object |
| 1426 | 1568 | * @return bool Permission status |
| 1427 | 1569 | */ |
| 1428 | 1570 | public function check_optimize_permissions(WP_REST_Request $request): bool { |
| 1429 | - // Check user capability | |
| 1430 | - 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')) { | |
| 1431 | 1576 | return false; |
| 1432 | 1577 | } |
| 1433 | 1578 | |
| 1434 | 1579 | // SECURITY: Verify nonce for CSRF protection |
| @@ -1478,33 +1623,11 @@ | ||
| 1478 | 1623 | /** |
| 1479 | 1624 | * Helper methods |
| 1480 | 1625 | */ |
| 1481 | 1626 | |
| 1482 | - /** | |
| 1483 | - * Verify request nonce for CSRF protection | |
| 1484 | - * | |
| 1485 | - * @since 1.0.0 | |
| 1486 | - * | |
| 1487 | - * @param WP_REST_Request $request Request object | |
| 1488 | - * @return bool Whether nonce is valid | |
| 1489 | - */ | |
| 1490 | - private function verify_request_nonce(WP_REST_Request $request): bool { | |
| 1491 | - // Get nonce from header (preferred method for REST API) | |
| 1492 | - $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). | |
| 1493 | 1629 | |
| 1494 | - // Fallback to parameter if header not present | |
| 1495 | - if (!$nonce) { | |
| 1496 | - $nonce = $request->get_param('_wpnonce'); | |
| 1497 | - } | |
| 1498 | - | |
| 1499 | - // Verify nonce | |
| 1500 | - if (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) { | |
| 1501 | - return false; | |
| 1502 | - } | |
| 1503 | - | |
| 1504 | - return true; | |
| 1505 | - } | |
| 1506 | - | |
| 1507 | 1630 | /** |
| 1508 | 1631 | * Generate schema preview |
| 1509 | 1632 | * |
| 1510 | 1633 | * @since 1.0.0 |
| @@ -1691,9 +1814,9 @@ | ||
| 1691 | 1814 | 'enum' => [ |
| 1692 | 1815 | 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle', |
| 1693 | 1816 | 'ScholarlyArticle', 'Report', 'Product', 'Organization', |
| 1694 | 1817 | 'LocalBusiness', 'Person', 'WebSite', 'FAQPage', |
| 1695 | - 'Event', 'HowTo', 'SoftwareApplication' | |
| 1818 | + 'Event', 'HowTo', 'SoftwareApplication', 'Review', 'VideoObject' | |
| 1696 | 1819 | ] |
| 1697 | 1820 | ], |
| 1698 | 1821 | 'description' => 'Schema types to generate' |
| 1699 | 1822 | ], |
| @@ -1738,9 +1861,9 @@ | ||
| 1738 | 1861 | 'enum' => [ |
| 1739 | 1862 | 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle', |
| 1740 | 1863 | 'ScholarlyArticle', 'Report', 'Product', 'Organization', |
| 1741 | 1864 | 'LocalBusiness', 'Person', 'WebSite', 'WebPage', 'FAQPage', |
| 1742 | - 'SoftwareApplication', 'Event', 'Recipe', 'HowTo' | |
| 1865 | + 'SoftwareApplication', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject' | |
| 1743 | 1866 | ], |
| 1744 | 1867 | 'description' => 'Schema type' |
| 1745 | 1868 | ], |
| 1746 | 1869 | 'options' => [ |
| @@ -1804,9 +1927,9 @@ | ||
| 1804 | 1927 | 'type' => 'string', |
| 1805 | 1928 | 'enum' => [ |
| 1806 | 1929 | 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness', |
| 1807 | 1930 | 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication', |
| 1808 | - 'BreadcrumbList', 'Event', 'Recipe', 'HowTo' | |
| 1931 | + 'BreadcrumbList', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject' | |
| 1809 | 1932 | ], |
| 1810 | 1933 | 'description' => 'Schema type' |
| 1811 | 1934 | ], |
| 1812 | 1935 | 'options' => [ |
| @@ -1836,9 +1959,9 @@ | ||
| 1836 | 1959 | 'type' => 'string', |
| 1837 | 1960 | 'enum' => [ |
| 1838 | 1961 | 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness', |
| 1839 | 1962 | 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication', |
| 1840 | - 'BreadcrumbList', 'Event', 'Recipe', 'HowTo' | |
| 1963 | + 'BreadcrumbList', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject' | |
| 1841 | 1964 | ], |
| 1842 | 1965 | 'description' => 'Schema type' |
| 1843 | 1966 | ] |
| 1844 | 1967 | ]; |
| @@ -1961,8 +2084,15 @@ | ||
| 1961 | 2084 | ); |
| 1962 | 2085 | } |
| 1963 | 2086 | $context_type = $context_validation['sanitized_data']['context_type']; |
| 1964 | 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; | |
| 1965 | 2095 | } |
| 1966 | 2096 | |
| 1967 | 2097 | // Drop unrecognized keys so arbitrary client-supplied keys aren't |
| 1968 | 2098 | // persisted as settings rows (storage bloat / settings drift). |
| @@ -2040,14 +2170,32 @@ | ||
| 2040 | 2170 | * @param string $context_type Context type (site/post/page/product). |
| 2041 | 2171 | * @return array Settings limited to known keys. |
| 2042 | 2172 | */ |
| 2043 | 2173 | private function filter_known_setting_keys(array $settings, string $context_type): array { |
| 2044 | - $known = array_keys(\ThinkRank\Config\Schema_Settings_Config::get_default_settings($context_type)); | |
| 2045 | - // Keys stored/consumed by adjacent features that share the settings | |
| 2046 | - // store but aren't part of the schema defaults. | |
| 2047 | - $known = array_merge($known, array_keys(\ThinkRank\Config\Schema_Settings_Config::get_settings_schema($context_type)), [ | |
| 2048 | - 'business_name', 'site_name', 'logo_url', 'performance_tracking', | |
| 2049 | - ]); | |
| 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 | + */ | |
| 2050 | 2198 | $known = apply_filters('thinkrank_schema_known_setting_keys', $known, $context_type); |
| 2051 | 2199 | |
| 2052 | 2200 | return array_intersect_key($settings, array_flip($known)); |
| 2053 | 2201 | } |