| @@ -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'], |
| @@ -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,8 +434,87 @@ | ||
| 415 | 434 | } |
| 416 | 435 | } |
| 417 | 436 | |
| 418 | 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 | + /** | |
| 419 | 517 | * Fetch a remote URL for schema import. |
| 420 | 518 | * |
| 421 | 519 | * Delegates to the shared SSRF guard, which follows redirects manually and |
| 422 | 520 | * re-validates the resolved host against the block list on every hop — |
| @@ -429,8 +527,12 @@ | ||
| 429 | 527 | private function fetch_import_url(string $url) { |
| 430 | 528 | return \ThinkRank\Core\Url_Safety::safe_remote_get($url, [ |
| 431 | 529 | 'timeout' => 15, |
| 432 | 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, | |
| 433 | 535 | ]); |
| 434 | 536 | } |
| 435 | 537 | |
| 436 | 538 | /** |
| @@ -526,23 +628,10 @@ | ||
| 526 | 628 | |
| 527 | 629 | // SECURITY: Sanitize schema_form_data if provided |
| 528 | 630 | $schema_form_data = $request->get_param('schema_form_data'); |
| 529 | 631 | if ($schema_form_data && is_array($schema_form_data)) { |
| 530 | - // Sanitize all form fields | |
| 531 | - $sanitized_form_data = []; | |
| 532 | - foreach ($schema_form_data as $key => $value) { | |
| 533 | - if (is_string($value)) { | |
| 534 | - $sanitized_form_data[sanitize_key($key)] = sanitize_text_field($value); | |
| 535 | - } elseif (is_array($value)) { | |
| 536 | - // Handle array values (like features, steps, etc.) | |
| 537 | - $sanitized_form_data[sanitize_key($key)] = array_map('sanitize_text_field', $value); | |
| 538 | - } elseif (is_numeric($value)) { | |
| 539 | - $sanitized_form_data[sanitize_key($key)] = floatval($value); | |
| 540 | - } | |
| 541 | - } | |
| 542 | - | |
| 543 | 632 | // Add schema_form_data to options so schema manager can use it |
| 544 | - $options['schema_form_data'] = $sanitized_form_data; | |
| 633 | + $options['schema_form_data'] = $this->sanitize_schema_form_data($schema_form_data); | |
| 545 | 634 | } |
| 546 | 635 | |
| 547 | 636 | // Generate schema markup with sanitized inputs |
| 548 | 637 | $generation_results = $this->schema_manager->generate_schema_markup( |
| @@ -704,8 +793,9 @@ | ||
| 704 | 793 | } |
| 705 | 794 | |
| 706 | 795 | // SECURITY: Validate each schema in the data |
| 707 | 796 | $sanitized_schema_data = []; |
| 797 | + $skipped_schemas = []; | |
| 708 | 798 | foreach ($schema_data as $schema_key => $schema_content) { |
| 709 | 799 | $schema_key = sanitize_text_field($schema_key); |
| 710 | 800 | |
| 711 | 801 | if (!is_array($schema_content)) { |
| @@ -716,11 +806,22 @@ | ||
| 716 | 806 | ); |
| 717 | 807 | } |
| 718 | 808 | |
| 719 | 809 | // Ensure schema has required structure fields before validation |
| 720 | - // Use @type from schema content if available, otherwise fall back to key | |
| 721 | - $schema_type = isset($schema_content['@type']) ? sanitize_text_field($schema_content['@type']) : $schema_key; | |
| 722 | - | |
| 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 | + | |
| 723 | 824 | if (!isset($schema_content['@type'])) { |
| 724 | 825 | $schema_content['@type'] = $schema_type; |
| 725 | 826 | } |
| 726 | 827 | if (!isset($schema_content['@context'])) { |
| @@ -726,20 +827,21 @@ | ||
| 726 | 827 | if (!isset($schema_content['@context'])) { |
| 727 | 828 | $schema_content['@context'] = 'https://schema.org'; |
| 728 | 829 | } |
| 729 | 830 | |
| 730 | - // 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). | |
| 731 | 835 | $input_validation = $this->input_validator->validate_schema_data($schema_content, $schema_type); |
| 836 | + | |
| 732 | 837 | if (!$input_validation['valid']) { |
| 733 | - return new WP_Error( | |
| 734 | - 'schema_validation_failed', | |
| 735 | - "Schema validation failed for {$schema_type}: " . implode(', ', $input_validation['errors']), | |
| 736 | - [ | |
| 737 | - 'status' => 400, | |
| 738 | - 'schema_type' => $schema_type, | |
| 739 | - 'validation_errors' => $input_validation['errors'] | |
| 740 | - ] | |
| 741 | - ); | |
| 838 | + $skipped_schemas[] = [ | |
| 839 | + 'key' => $schema_key, | |
| 840 | + 'type' => $schema_type, | |
| 841 | + 'errors' => $input_validation['errors'], | |
| 842 | + ]; | |
| 843 | + continue; | |
| 742 | 844 | } |
| 743 | 845 | |
| 744 | 846 | // Store using the key (which may be unique like "Article-1") |
| 745 | 847 | $sanitized_schema_data[$schema_key] = $input_validation['sanitized_data']; |
| @@ -744,11 +846,33 @@ | ||
| 744 | 846 | // Store using the key (which may be unique like "Article-1") |
| 745 | 847 | $sanitized_schema_data[$schema_key] = $input_validation['sanitized_data']; |
| 746 | 848 | } |
| 747 | 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 | + | |
| 748 | 867 | // SECURITY: Sanitize options |
| 749 | 868 | $options = $this->input_validator->sanitize_options($request->get_param('options') ?? []); |
| 750 | 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 | + | |
| 751 | 875 | // Deploy schema markup with sanitized data |
| 752 | 876 | $deployment_results = $this->schema_manager->deploy_schema_markup( |
| 753 | 877 | $context_type, |
| 754 | 878 | $context_id, |
| @@ -755,14 +879,28 @@ | ||
| 755 | 879 | $sanitized_schema_data, |
| 756 | 880 | $options |
| 757 | 881 | ); |
| 758 | 882 | |
| 759 | - return new WP_REST_Response([ | |
| 883 | + $response = [ | |
| 760 | 884 | 'success' => true, |
| 761 | 885 | 'data' => $deployment_results, |
| 762 | 886 | 'message' => 'Schema markup deployed successfully' |
| 763 | - ], 200); | |
| 887 | + ]; | |
| 764 | 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 | + | |
| 765 | 903 | } catch (\Exception $e) { |
| 766 | 904 | return new WP_Error( |
| 767 | 905 | 'deployment_failed', |
| 768 | 906 | 'Schema deployment failed: ' . $e->getMessage(), |
| @@ -900,17 +1038,17 @@ | ||
| 900 | 1038 | * @return WP_REST_Response|WP_Error Response object or error |
| 901 | 1039 | */ |
| 902 | 1040 | public function get_deployed_schemas(WP_REST_Request $request) { |
| 903 | 1041 | try { |
| 904 | - $context_type = $request->get_param('context_type') ?? 'site'; | |
| 905 | - $context_id = $request->get_param('context_id'); | |
| 906 | - | |
| 907 | - // Convert context_id to int if it's a valid numeric string, otherwise null | |
| 908 | - if ($context_id !== null && is_numeric($context_id)) { | |
| 909 | - $context_id = (int) $context_id; | |
| 910 | - } else { | |
| 911 | - $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; | |
| 912 | 1049 | } |
| 1050 | + [$context_type, $context_id] = $context; | |
| 913 | 1051 | |
| 914 | 1052 | $deployed_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id); |
| 915 | 1053 | |
| 916 | 1054 | return new WP_REST_Response([ |
| @@ -1352,10 +1490,13 @@ | ||
| 1352 | 1490 | * @param WP_REST_Request $request Request object |
| 1353 | 1491 | * @return bool Permission status |
| 1354 | 1492 | */ |
| 1355 | 1493 | public function check_generate_permissions(WP_REST_Request $request): bool { |
| 1356 | - // Check user capability | |
| 1357 | - 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')) { | |
| 1358 | 1499 | return false; |
| 1359 | 1500 | } |
| 1360 | 1501 | |
| 1361 | 1502 | // SECURITY: Verify nonce for CSRF protection |
| @@ -1370,10 +1511,13 @@ | ||
| 1370 | 1511 | * @param WP_REST_Request $request Request object |
| 1371 | 1512 | * @return bool Permission status |
| 1372 | 1513 | */ |
| 1373 | 1514 | public function check_validate_permissions(WP_REST_Request $request): bool { |
| 1374 | - // Check user capability | |
| 1375 | - 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')) { | |
| 1376 | 1520 | return false; |
| 1377 | 1521 | } |
| 1378 | 1522 | |
| 1379 | 1523 | // SECURITY: Verify nonce for CSRF protection |
| @@ -1388,10 +1532,13 @@ | ||
| 1388 | 1532 | * @param WP_REST_Request $request Request object |
| 1389 | 1533 | * @return bool Permission status |
| 1390 | 1534 | */ |
| 1391 | 1535 | public function check_deploy_permissions(WP_REST_Request $request): bool { |
| 1392 | - // Check user capability | |
| 1393 | - 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')) { | |
| 1394 | 1541 | return false; |
| 1395 | 1542 | } |
| 1396 | 1543 | |
| 1397 | 1544 | // SECURITY: Verify nonce for CSRF protection |
| @@ -1420,10 +1567,13 @@ | ||
| 1420 | 1567 | * @param WP_REST_Request $request Request object |
| 1421 | 1568 | * @return bool Permission status |
| 1422 | 1569 | */ |
| 1423 | 1570 | public function check_optimize_permissions(WP_REST_Request $request): bool { |
| 1424 | - // Check user capability | |
| 1425 | - 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')) { | |
| 1426 | 1576 | return false; |
| 1427 | 1577 | } |
| 1428 | 1578 | |
| 1429 | 1579 | // SECURITY: Verify nonce for CSRF protection |
| @@ -1473,89 +1623,12 @@ | ||
| 1473 | 1623 | /** |
| 1474 | 1624 | * Helper methods |
| 1475 | 1625 | */ |
| 1476 | 1626 | |
| 1477 | - /** | |
| 1478 | - * Verify request nonce for CSRF protection | |
| 1479 | - * | |
| 1480 | - * @since 1.0.0 | |
| 1481 | - * | |
| 1482 | - * @param WP_REST_Request $request Request object | |
| 1483 | - * @return bool Whether nonce is valid | |
| 1484 | - */ | |
| 1485 | - private function verify_request_nonce(WP_REST_Request $request): bool { | |
| 1486 | - // Get nonce from header (preferred method for REST API) | |
| 1487 | - $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). | |
| 1488 | 1629 | |
| 1489 | - // Fallback to parameter if header not present | |
| 1490 | - if (!$nonce) { | |
| 1491 | - $nonce = $request->get_param('_wpnonce'); | |
| 1492 | - } | |
| 1493 | - | |
| 1494 | - // Verify nonce | |
| 1495 | - if (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) { | |
| 1496 | - return false; | |
| 1497 | - } | |
| 1498 | - | |
| 1499 | - return true; | |
| 1500 | - } | |
| 1501 | - | |
| 1502 | 1630 | /** |
| 1503 | - * Validate context type and ID | |
| 1504 | - * | |
| 1505 | - * @since 1.0.0 | |
| 1506 | - * | |
| 1507 | - * @param string $context_type Context type | |
| 1508 | - * @param int|null $context_id Context ID | |
| 1509 | - * @return bool Validation status | |
| 1510 | - */ | |
| 1511 | - private function validate_context(string $context_type, ?int $context_id) { | |
| 1512 | - $valid_types = ['site', 'post', 'page', 'product']; | |
| 1513 | - | |
| 1514 | - $invalid = new WP_Error( | |
| 1515 | - 'invalid_context', | |
| 1516 | - 'Invalid context type or ID provided', | |
| 1517 | - ['status' => 400] | |
| 1518 | - ); | |
| 1519 | - | |
| 1520 | - if (!in_array($context_type, $valid_types, true)) { | |
| 1521 | - return $invalid; | |
| 1522 | - } | |
| 1523 | - | |
| 1524 | - if ($context_type !== 'site' && (!$context_id || $context_id <= 0)) { | |
| 1525 | - return $invalid; | |
| 1526 | - } | |
| 1527 | - | |
| 1528 | - if ($context_id && !get_post($context_id)) { | |
| 1529 | - return $invalid; | |
| 1530 | - } | |
| 1531 | - | |
| 1532 | - // SECURITY: everything above establishes that the context *exists*, not | |
| 1533 | - // that this caller may see it. `edit_post` is a meta capability, so | |
| 1534 | - // map_meta_cap() resolves authorship, published state and | |
| 1535 | - // edit_others_posts for this specific post — the same check | |
| 1536 | - // Schema_Input_Validator::validate_context_ownership() makes on the | |
| 1537 | - // write paths, and the one class-social-media-endpoint.php already makes | |
| 1538 | - // on its own context routes. Without it a delegated Schema Manager can | |
| 1539 | - // walk context_id and read SEO data for drafts, pending posts and other | |
| 1540 | - // authors' content. | |
| 1541 | - // | |
| 1542 | - // Site context is deliberately left to the route's capability gate. | |
| 1543 | - // The write paths demand manage_options for it, but applying that here | |
| 1544 | - // would stop a delegated Schema Manager reading site-level schema at | |
| 1545 | - // all, which is the point of delegating the section. | |
| 1546 | - if ($context_type !== 'site' && !current_user_can('edit_post', $context_id)) { | |
| 1547 | - return new WP_Error( | |
| 1548 | - 'rest_forbidden', | |
| 1549 | - 'You are not allowed to access this content.', | |
| 1550 | - ['status' => 403] | |
| 1551 | - ); | |
| 1552 | - } | |
| 1553 | - | |
| 1554 | - return true; | |
| 1555 | - } | |
| 1556 | - | |
| 1557 | - /** | |
| 1558 | 1631 | * Generate schema preview |
| 1559 | 1632 | * |
| 1560 | 1633 | * @since 1.0.0 |
| 1561 | 1634 | * |
| @@ -1730,10 +1803,13 @@ | ||
| 1730 | 1803 | 'minimum' => 1, |
| 1731 | 1804 | 'description' => 'Context ID (not required for site context)' |
| 1732 | 1805 | ], |
| 1733 | 1806 | 'schema_types' => [ |
| 1734 | - '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, | |
| 1735 | 1810 | 'type' => 'array', |
| 1811 | + 'minItems' => 1, | |
| 1736 | 1812 | 'items' => [ |
| 1737 | 1813 | 'type' => 'string', |
| 1738 | 1814 | 'enum' => [ |
| 1739 | 1815 | 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle', |
| @@ -1738,9 +1814,9 @@ | ||
| 1738 | 1814 | 'enum' => [ |
| 1739 | 1815 | 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle', |
| 1740 | 1816 | 'ScholarlyArticle', 'Report', 'Product', 'Organization', |
| 1741 | 1817 | 'LocalBusiness', 'Person', 'WebSite', 'FAQPage', |
| 1742 | - 'Event', 'HowTo', 'SoftwareApplication' | |
| 1818 | + 'Event', 'HowTo', 'SoftwareApplication', 'Review', 'VideoObject' | |
| 1743 | 1819 | ] |
| 1744 | 1820 | ], |
| 1745 | 1821 | 'description' => 'Schema types to generate' |
| 1746 | 1822 | ], |
| @@ -1785,9 +1861,9 @@ | ||
| 1785 | 1861 | 'enum' => [ |
| 1786 | 1862 | 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle', |
| 1787 | 1863 | 'ScholarlyArticle', 'Report', 'Product', 'Organization', |
| 1788 | 1864 | 'LocalBusiness', 'Person', 'WebSite', 'WebPage', 'FAQPage', |
| 1789 | - 'SoftwareApplication', 'Event', 'Recipe', 'HowTo' | |
| 1865 | + 'SoftwareApplication', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject' | |
| 1790 | 1866 | ], |
| 1791 | 1867 | 'description' => 'Schema type' |
| 1792 | 1868 | ], |
| 1793 | 1869 | 'options' => [ |
| @@ -1851,9 +1927,9 @@ | ||
| 1851 | 1927 | 'type' => 'string', |
| 1852 | 1928 | 'enum' => [ |
| 1853 | 1929 | 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness', |
| 1854 | 1930 | 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication', |
| 1855 | - 'BreadcrumbList', 'Event', 'Recipe', 'HowTo' | |
| 1931 | + 'BreadcrumbList', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject' | |
| 1856 | 1932 | ], |
| 1857 | 1933 | 'description' => 'Schema type' |
| 1858 | 1934 | ], |
| 1859 | 1935 | 'options' => [ |
| @@ -1883,9 +1959,9 @@ | ||
| 1883 | 1959 | 'type' => 'string', |
| 1884 | 1960 | 'enum' => [ |
| 1885 | 1961 | 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness', |
| 1886 | 1962 | 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication', |
| 1887 | - 'BreadcrumbList', 'Event', 'Recipe', 'HowTo' | |
| 1963 | + 'BreadcrumbList', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject' | |
| 1888 | 1964 | ], |
| 1889 | 1965 | 'description' => 'Schema type' |
| 1890 | 1966 | ] |
| 1891 | 1967 | ]; |
| @@ -1935,10 +2011,15 @@ | ||
| 1935 | 2011 | * @return WP_REST_Response|WP_Error Response object or error |
| 1936 | 2012 | */ |
| 1937 | 2013 | public function get_settings(WP_REST_Request $request) { |
| 1938 | 2014 | try { |
| 1939 | - $context_type = $request->get_param('context_type') ?? 'site'; | |
| 1940 | - $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; | |
| 1941 | 2022 | |
| 1942 | 2023 | // Get settings from schema manager |
| 1943 | 2024 | $settings = $this->schema_manager->get_settings($context_type, $context_id); |
| 1944 | 2025 | |
| @@ -2003,8 +2084,15 @@ | ||
| 2003 | 2084 | ); |
| 2004 | 2085 | } |
| 2005 | 2086 | $context_type = $context_validation['sanitized_data']['context_type']; |
| 2006 | 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; | |
| 2007 | 2095 | } |
| 2008 | 2096 | |
| 2009 | 2097 | // Drop unrecognized keys so arbitrary client-supplied keys aren't |
| 2010 | 2098 | // persisted as settings rows (storage bloat / settings drift). |
| @@ -2082,14 +2170,32 @@ | ||
| 2082 | 2170 | * @param string $context_type Context type (site/post/page/product). |
| 2083 | 2171 | * @return array Settings limited to known keys. |
| 2084 | 2172 | */ |
| 2085 | 2173 | private function filter_known_setting_keys(array $settings, string $context_type): array { |
| 2086 | - $known = array_keys(\ThinkRank\Config\Schema_Settings_Config::get_default_settings($context_type)); | |
| 2087 | - // Keys stored/consumed by adjacent features that share the settings | |
| 2088 | - // store but aren't part of the schema defaults. | |
| 2089 | - $known = array_merge($known, array_keys(\ThinkRank\Config\Schema_Settings_Config::get_settings_schema($context_type)), [ | |
| 2090 | - 'business_name', 'site_name', 'logo_url', 'performance_tracking', | |
| 2091 | - ]); | |
| 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 | + */ | |
| 2092 | 2198 | $known = apply_filters('thinkrank_schema_known_setting_keys', $known, $context_type); |
| 2093 | 2199 | |
| 2094 | 2200 | return array_intersect_key($settings, array_flip($known)); |
| 2095 | 2201 | } |