PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
← All changes | includes/api/class-schema-endpoint.php +248 -142 2.0.02.7.0 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,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 }