PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 All 57 releases
← All changes | includes/api/class-schema-endpoint.php +280 -128 1.30.0 → 2.14.1 View file →
@@ -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,42 @@
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 +
875 + // A skipped entry was sent, so the user still wants it on the page;
876 + // it only failed validation. Retiring it as "missing from the
877 + // payload" took a live Organization down behind a success toast
878 + // (#949) — leave whatever is deployed for it in place.
879 + $options['retain_types'] = array_values(array_unique(array_merge(
880 + wp_list_pluck($skipped_schemas, 'key'),
881 + wp_list_pluck($skipped_schemas, 'type')
882 + )));
883 +
751 884 // Deploy schema markup with sanitized data
752 885 $deployment_results = $this->schema_manager->deploy_schema_markup(
753 886 $context_type,
754 887 $context_id,
@@ -755,14 +888,29 @@
755 888 $sanitized_schema_data,
756 889 $options
757 890 );
758 891
759 - return new WP_REST_Response([
892 + $response = [
760 893 'success' => true,
761 894 'data' => $deployment_results,
762 895 'message' => 'Schema markup deployed successfully'
763 - ], 200);
896 + ];
764 897
898 + // Report what was skipped so the UI can say "3 deployed, 1 skipped"
899 + // rather than silently dropping entries (#468).
900 + if (!empty($skipped_schemas)) {
901 + $response['skipped'] = $skipped_schemas;
902 + $response['partial'] = true;
903 + $response['message'] = sprintf(
904 + /* translators: 1: number deployed, 2: number skipped. */
905 + __('Deployed %1$d schema(s); skipped %2$d that failed validation.', 'thinkrank'),
906 + count($sanitized_schema_data),
907 + count($skipped_schemas)
908 + );
909 + }
910 +
911 + return new WP_REST_Response($response, 200);
912 +
765 913 } catch (\Exception $e) {
766 914 return new WP_Error(
767 915 'deployment_failed',
768 916 'Schema deployment failed: ' . $e->getMessage(),
@@ -900,17 +1048,17 @@
900 1048 * @return WP_REST_Response|WP_Error Response object or error
901 1049 */
902 1050 public function get_deployed_schemas(WP_REST_Request $request) {
903 1051 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;
1052 + // SECURITY: this route reads the schema deployed against a specific
1053 + // object. The thinkrank_schema capability authorises the section, not
1054 + // every post on the site, so the object itself has to be authorised
1055 + // before the read (#385).
1056 + $context = $this->resolve_request_context($request);
1057 + if (is_wp_error($context)) {
1058 + return $context;
912 1059 }
1060 + [$context_type, $context_id] = $context;
913 1061
914 1062 $deployed_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
915 1063
916 1064 return new WP_REST_Response([
@@ -940,15 +1088,13 @@
940 1088 try {
941 1089 $context_type = $request->get_param('context_type');
942 1090 $context_id = (int) $request->get_param('context_id');
943 1091
944 - // Validate context
945 - if (!$this->validate_context($context_type, $context_id)) {
946 - return new WP_Error(
947 - 'invalid_context',
948 - 'Invalid context type or ID provided',
949 - ['status' => 400]
950 - );
1092 + // Validate context and the caller's access to it. Returns true or a
1093 + // WP_Error carrying the right status (400 shape, 403 authorization).
1094 + $context_validation = $this->validate_context($context_type, $context_id);
1095 + if (is_wp_error($context_validation)) {
1096 + return $context_validation;
951 1097 }
952 1098
953 1099 // Get schema output data
954 1100 $schema_data = $this->schema_manager->get_output_data($context_type, $context_id);
@@ -1065,15 +1211,13 @@
1065 1211 $context_type = $request->get_param('context_type');
1066 1212 $context_id = (int) $request->get_param('context_id');
1067 1213 $options = $request->get_param('options') ?? [];
1068 1214
1069 - // Validate context
1070 - if (!$this->validate_context($context_type, $context_id)) {
1071 - return new WP_Error(
1072 - 'invalid_context',
1073 - 'Invalid context type or ID provided',
1074 - ['status' => 400]
1075 - );
1215 + // Validate context and the caller's access to it. Returns true or a
1216 + // WP_Error carrying the right status (400 shape, 403 authorization).
1217 + $context_validation = $this->validate_context($context_type, $context_id);
1218 + if (is_wp_error($context_validation)) {
1219 + return $context_validation;
1076 1220 }
1077 1221
1078 1222 // Track schema performance
1079 1223 $performance_data = $this->schema_manager->track_schema_performance(
@@ -1356,10 +1500,13 @@
1356 1500 * @param WP_REST_Request $request Request object
1357 1501 * @return bool Permission status
1358 1502 */
1359 1503 public function check_generate_permissions(WP_REST_Request $request): bool {
1360 - // Check user capability
1361 - if (!current_user_can('edit_posts')) {
1504 + // Gate on the Role Manager's schema capability, like the read and
1505 + // settings routes. Core post caps were both too loose in principle and
1506 + // too strict in practice: a role granted schema access but without
1507 + // publish_posts could not deploy (#457).
1508 + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1362 1509 return false;
1363 1510 }
1364 1511
1365 1512 // SECURITY: Verify nonce for CSRF protection
@@ -1374,10 +1521,13 @@
1374 1521 * @param WP_REST_Request $request Request object
1375 1522 * @return bool Permission status
1376 1523 */
1377 1524 public function check_validate_permissions(WP_REST_Request $request): bool {
1378 - // Check user capability
1379 - if (!current_user_can('edit_posts')) {
1525 + // Gate on the Role Manager's schema capability, like the read and
1526 + // settings routes. Core post caps were both too loose in principle and
1527 + // too strict in practice: a role granted schema access but without
1528 + // publish_posts could not deploy (#457).
1529 + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1380 1530 return false;
1381 1531 }
1382 1532
1383 1533 // SECURITY: Verify nonce for CSRF protection
@@ -1392,10 +1542,13 @@
1392 1542 * @param WP_REST_Request $request Request object
1393 1543 * @return bool Permission status
1394 1544 */
1395 1545 public function check_deploy_permissions(WP_REST_Request $request): bool {
1396 - // Check user capability
1397 - if (!current_user_can('publish_posts')) {
1546 + // Gate on the Role Manager's schema capability, like the read and
1547 + // settings routes. Core post caps were both too loose in principle and
1548 + // too strict in practice: a role granted schema access but without
1549 + // publish_posts could not deploy (#457).
1550 + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1398 1551 return false;
1399 1552 }
1400 1553
1401 1554 // SECURITY: Verify nonce for CSRF protection
@@ -1424,10 +1577,13 @@
1424 1577 * @param WP_REST_Request $request Request object
1425 1578 * @return bool Permission status
1426 1579 */
1427 1580 public function check_optimize_permissions(WP_REST_Request $request): bool {
1428 - // Check user capability
1429 - if (!current_user_can('edit_posts')) {
1581 + // Gate on the Role Manager's schema capability, like the read and
1582 + // settings routes. Core post caps were both too loose in principle and
1583 + // too strict in practice: a role granted schema access but without
1584 + // publish_posts could not deploy (#457).
1585 + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1430 1586 return false;
1431 1587 }
1432 1588
1433 1589 // SECURITY: Verify nonce for CSRF protection
@@ -1477,61 +1633,12 @@
1477 1633 /**
1478 1634 * Helper methods
1479 1635 */
1480 1636
1481 - /**
1482 - * Verify request nonce for CSRF protection
1483 - *
1484 - * @since 1.0.0
1485 - *
1486 - * @param WP_REST_Request $request Request object
1487 - * @return bool Whether nonce is valid
1488 - */
1489 - private function verify_request_nonce(WP_REST_Request $request): bool {
1490 - // Get nonce from header (preferred method for REST API)
1491 - $nonce = $request->get_header('X-WP-Nonce');
1637 + // verify_request_nonce() now comes from the shared CSRF_Protection trait
1638 + // used by the other endpoints; the local copy was identical (#457).
1492 1639
1493 - // Fallback to parameter if header not present
1494 - if (!$nonce) {
1495 - $nonce = $request->get_param('_wpnonce');
1496 - }
1497 -
1498 - // Verify nonce
1499 - if (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) {
1500 - return false;
1501 - }
1502 -
1503 - return true;
1504 - }
1505 -
1506 1640 /**
1507 - * Validate context type and ID
1508 - *
1509 - * @since 1.0.0
1510 - *
1511 - * @param string $context_type Context type
1512 - * @param int|null $context_id Context ID
1513 - * @return bool Validation status
1514 - */
1515 - private function validate_context(string $context_type, ?int $context_id): bool {
1516 - $valid_types = ['site', 'post', 'page', 'product'];
1517 -
1518 - if (!in_array($context_type, $valid_types, true)) {
1519 - return false;
1520 - }
1521 -
1522 - if ($context_type !== 'site' && (!$context_id || $context_id <= 0)) {
1523 - return false;
1524 - }
1525 -
1526 - if ($context_id && !get_post($context_id)) {
1527 - return false;
1528 - }
1529 -
1530 - return true;
1531 - }
1532 -
1533 - /**
1534 1641 * Generate schema preview
1535 1642 *
1536 1643 * @since 1.0.0
1537 1644 *
@@ -1706,10 +1813,13 @@
1706 1813 'minimum' => 1,
1707 1814 'description' => 'Context ID (not required for site context)'
1708 1815 ],
1709 1816 'schema_types' => [
1710 - 'required' => false,
1817 + // generate_schema() rejects a missing or empty value with a 400,
1818 + // so the schema has to say so too.
1819 + 'required' => true,
1711 1820 'type' => 'array',
1821 + 'minItems' => 1,
1712 1822 'items' => [
1713 1823 'type' => 'string',
1714 1824 'enum' => [
1715 1825 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle',
@@ -1714,9 +1824,15 @@
1714 1824 'enum' => [
1715 1825 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle',
1716 1826 'ScholarlyArticle', 'Report', 'Product', 'Organization',
1717 1827 'LocalBusiness', 'Person', 'WebSite', 'FAQPage',
1718 - 'Event', 'HowTo', 'SoftwareApplication'
1828 + 'Event', 'HowTo', 'SoftwareApplication', 'Review', 'VideoObject',
1829 + // The WebPage family (#624). This route is the one the
1830 + // per-page selector calls, and it did not accept even
1831 + // WebPage — so every entry in that dropdown was refused
1832 + // with a 400 before any of the registries below were
1833 + // consulted. Appended so existing ordering is unchanged.
1834 + 'WebPage', 'AboutPage', 'ContactPage', 'ProfilePage'
1719 1835 ]
1720 1836 ],
1721 1837 'description' => 'Schema types to generate'
1722 1838 ],
@@ -1761,9 +1877,11 @@
1761 1877 'enum' => [
1762 1878 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle',
1763 1879 'ScholarlyArticle', 'Report', 'Product', 'Organization',
1764 1880 'LocalBusiness', 'Person', 'WebSite', 'WebPage', 'FAQPage',
1765 - 'SoftwareApplication', 'Event', 'Recipe', 'HowTo'
1881 + 'SoftwareApplication', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject',
1882 + // WebPage's subtypes, which validate exactly as it does (#624).
1883 + 'AboutPage', 'ContactPage', 'ProfilePage'
1766 1884 ],
1767 1885 'description' => 'Schema type'
1768 1886 ],
1769 1887 'options' => [
@@ -1827,9 +1945,11 @@
1827 1945 'type' => 'string',
1828 1946 'enum' => [
1829 1947 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness',
1830 1948 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication',
1831 - 'BreadcrumbList', 'Event', 'Recipe', 'HowTo'
1949 + 'BreadcrumbList', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject',
1950 + // WebPage's subtypes, which carry the same properties (#624).
1951 + 'AboutPage', 'ContactPage', 'ProfilePage'
1832 1952 ],
1833 1953 'description' => 'Schema type'
1834 1954 ],
1835 1955 'options' => [
@@ -1859,9 +1979,11 @@
1859 1979 'type' => 'string',
1860 1980 'enum' => [
1861 1981 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness',
1862 1982 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication',
1863 - 'BreadcrumbList', 'Event', 'Recipe', 'HowTo'
1983 + 'BreadcrumbList', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject',
1984 + // WebPage's subtypes, which carry the same properties (#624).
1985 + 'AboutPage', 'ContactPage', 'ProfilePage'
1864 1986 ],
1865 1987 'description' => 'Schema type'
1866 1988 ]
1867 1989 ];
@@ -1911,10 +2033,15 @@
1911 2033 * @return WP_REST_Response|WP_Error Response object or error
1912 2034 */
1913 2035 public function get_settings(WP_REST_Request $request) {
1914 2036 try {
1915 - $context_type = $request->get_param('context_type') ?? 'site';
1916 - $context_id = $request->get_param('context_id') ?? null;
2037 + // SECURITY: the settings this returns are per-object. save_settings()
2038 + // already authorises the object; the read has to as well (#385).
2039 + $context = $this->resolve_request_context($request);
2040 + if (is_wp_error($context)) {
2041 + return $context;
2042 + }
2043 + [$context_type, $context_id] = $context;
1917 2044
1918 2045 // Get settings from schema manager
1919 2046 $settings = $this->schema_manager->get_settings($context_type, $context_id);
1920 2047
@@ -1979,8 +2106,15 @@
1979 2106 );
1980 2107 }
1981 2108 $context_type = $context_validation['sanitized_data']['context_type'];
1982 2109 $context_id = $context_validation['sanitized_data']['context_id'];
2110 + } else {
2111 + // Site settings are keyed on a NULL context_id. Passing the
2112 + // client's value straight through meant a stray context_id
2113 + // wrote a row at an arbitrary id, returned 200, and was never
2114 + // read back by anything (#470). validate_context_parameters()
2115 + // already normalises this internally for other contexts.
2116 + $context_id = null;
1983 2117 }
1984 2118
1985 2119 // Drop unrecognized keys so arbitrary client-supplied keys aren't
1986 2120 // persisted as settings rows (storage bloat / settings drift).
@@ -2058,14 +2192,32 @@
2058 2192 * @param string $context_type Context type (site/post/page/product).
2059 2193 * @return array Settings limited to known keys.
2060 2194 */
2061 2195 private function filter_known_setting_keys(array $settings, string $context_type): array {
2062 - $known = array_keys(\ThinkRank\Config\Schema_Settings_Config::get_default_settings($context_type));
2063 - // Keys stored/consumed by adjacent features that share the settings
2064 - // store but aren't part of the schema defaults.
2065 - $known = array_merge($known, array_keys(\ThinkRank\Config\Schema_Settings_Config::get_settings_schema($context_type)), [
2066 - 'business_name', 'site_name', 'logo_url', 'performance_tracking',
2067 - ]);
2196 + // Defer to the manager instead of maintaining a parallel list here.
2197 + // The endpoint's own list ignored additional_setting_keys() and
2198 + // dynamic_setting_key_patterns() — the mechanism #452 added so new form
2199 + // families stop getting dropped — so the two disagreed in both
2200 + // directions: the four enable_*_schema toggles and the software_/howto_/
2201 + // product_ families were dropped here but accepted by the manager, while
2202 + // deployment_method, site_name and performance_tracking survived here
2203 + // only to be dropped one layer down (#470).
2204 + $known = [];
2205 +
2206 + foreach (array_keys($settings) as $key) {
2207 + if ($this->schema_manager->accepts_setting_key((string) $key, $context_type)) {
2208 + $known[] = (string) $key;
2209 + }
2210 + }
2211 +
2212 + /**
2213 + * Filter the schema setting keys the REST endpoint will persist.
2214 + *
2215 + * @since 1.13.0
2216 + *
2217 + * @param string[] $known Keys accepted by the schema manager.
2218 + * @param string $context_type Context type.
2219 + */
2068 2220 $known = apply_filters('thinkrank_schema_known_setting_keys', $known, $context_type);
2069 2221
2070 2222 return array_intersect_key($settings, array_flip($known));
2071 2223 }