PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.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 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
← All changes | includes/api/class-settings-management-endpoint.php +436 -33 1.28.0 → 2.10.0 View file →
@@ -92,10 +92,15 @@
92 92 'social_media' => 'Social Media & Open Graph',
93 93 'sitemap' => 'XML Sitemap Management',
94 94 'integrations' => 'External Integrations',
95 95 'analytics_integration' => 'Analytics Integration',
96 - 'seo_analytics' => 'SEO Analytics & Intelligence',
97 - 'global_defaults' => 'Global Default Settings'
96 + 'seo_analytics' => 'SEO Analytics & Intelligence'
97 + // 'global_defaults' was listed here but is registered in no settings
98 + // store and read by no client — the only mention in the codebase was
99 + // this label. Every save against it reached the compound write with
100 + // nothing to persist to and answered 500, so accepting the name only
101 + // promised a category that could never be stored. It now falls through
102 + // to the 400 invalid_category branch like any other unknown name (#371).
98 103 ];
99 104
100 105 /**
101 106 * Constructor
@@ -116,9 +121,14 @@
116 121 */
117 122 private array $seo_manager_classes = [
118 123 'site_identity' => Site_Identity_Manager::class,
119 124 'performance_monitoring' => Performance_Monitoring_Manager::class,
120 - 'ai_content_analyzer' => AI_Content_Analyzer::class,
125 + // Keyed by the endpoint's own category name. It was 'ai_content_analyzer',
126 + // which appears in no other registry, so the route rejected it with 400
127 + // invalid_category and this manager was never reachable — while the
128 + // endpoint's actual category, 'content_analysis', had no manager and
129 + // therefore nowhere to persist (#371).
130 + 'content_analysis' => AI_Content_Analyzer::class,
121 131 'content_optimization' => Content_Optimization_Manager::class,
122 132 'schema_management' => Schema_Management_System::class,
123 133 'social_media' => Social_Meta_Manager::class,
124 134 'sitemap' => Sitemap_Generator::class,
@@ -149,8 +159,39 @@
149 159 return $this->seo_managers[$category];
150 160 }
151 161
152 162 /**
163 + * Read a category from whichever store actually owns it.
164 + *
165 + * The generic store returns `[]` for the eight SEO categories: it looks for
166 + * rows whose key carries a `<category>_` prefix, and the rows carry no such
167 + * prefix — `social_media` is stored as `social_meta`, `schema_management` as
168 + * `schema_management_system`, and their keys are bare (`og_site_name`). So a
169 + * direct `Settings_Manager::get_settings()` reports a configured site as
170 + * having no settings at all.
171 + *
172 + * Writes never had the problem, because the write path already falls back to
173 + * the owning manager. That asymmetry is what made this invisible from the UI
174 + * and dangerous underneath it: the pre-reset rollback snapshotted `[]` and
175 + * then defaults were written over live settings, so Reset could not be undone
176 + * (#689). Every read goes through here now, so there is one place to be wrong.
177 + *
178 + * @since 2.7.0
179 + *
180 + * @param string $category Category key.
181 + * @param string $context_type Optional. Context type. Default 'site'.
182 + * @param int|null $context_id Optional. Context ID.
183 + * @return array The category's stored settings.
184 + */
185 + private function read_category(string $category, string $context_type = 'site', ?int $context_id = null): array {
186 + if ($this->has_seo_manager($category)) {
187 + return (array) $this->get_seo_manager($category)->get_settings($context_type, $context_id);
188 + }
189 +
190 + return (array) $this->settings_manager->get_settings($category, $context_type, $context_id);
191 + }
192 +
193 + /**
153 194 * Register API routes
154 195 *
155 196 * @since 1.0.0
156 197 */
@@ -247,9 +288,9 @@
247 288 [
248 289 [
249 290 'methods' => 'POST',
250 291 'callback' => [$this, 'import_settings'],
251 - 'permission_callback' => [$this, 'check_manage_permissions'],
292 + 'permission_callback' => [$this, 'check_admin_permissions'],
252 293 'args' => $this->get_import_args()
253 294 ]
254 295 ]
255 296 );
@@ -288,9 +329,9 @@
288 329 [
289 330 [
290 331 'methods' => 'POST',
291 332 'callback' => [$this, 'reset_settings'],
292 - 'permission_callback' => [$this, 'check_manage_permissions'],
333 + 'permission_callback' => [$this, 'check_admin_permissions'],
293 334 'args' => $this->get_reset_args()
294 335 ]
295 336 ]
296 337 );
@@ -302,9 +343,9 @@
302 343 [
303 344 [
304 345 'methods' => 'POST',
305 346 'callback' => [$this, 'add_performance_indexes'],
306 - 'permission_callback' => [$this, 'check_manage_permissions']
347 + 'permission_callback' => [$this, 'check_admin_permissions']
307 348 ]
308 349 ]
309 350 );
310 351 }
@@ -321,8 +362,9 @@
321 362 'openai_api_key',
322 363 'claude_api_key',
323 364 'gemini_api_key',
324 365 'openrouter_api_key',
366 + 'openai_compatible_api_key',
325 367 'google_analytics_api_key',
326 368 'google_search_console_api_key',
327 369 'google_pagespeed_api_key',
328 370 'google_access_token',
@@ -378,8 +420,96 @@
378 420 return $settings;
379 421 }
380 422
381 423 /**
424 + * Redact secrets from a single category's flat key => value map.
425 + *
426 + * Convenience wrapper so the single-category response shapes get the same
427 + * treatment as the global map — no response path may return a cleartext
428 + * secret.
429 + *
430 + * @param string $category Category slug.
431 + * @param array $settings Flat key => value map for that category.
432 + * @return array Redacted flat map.
433 + */
434 + private function redact_category_settings(string $category, array $settings): array {
435 + $redacted = $this->redact_sensitive_settings([$category => $settings]);
436 + return $redacted[$category] ?? [];
437 + }
438 +
439 + /**
440 + * Drop masked secrets from an incoming write payload.
441 + *
442 + * Read responses return secrets masked ("••••abcd"). A client that GETs a
443 + * settings map and POSTs it straight back would otherwise persist the mask
444 + * over the real credential. Any sensitive key whose incoming value still
445 + * carries the mask marker is removed so the stored value is left untouched;
446 + * a genuinely new secret (no marker) writes through normally.
447 + *
448 + * @param array $settings Flat key => value map from the request.
449 + * @return array Map with masked secret values removed.
450 + */
451 + /**
452 + * Drop setting keys the category does not define.
453 + *
454 + * The known set is whatever describes the category: the generic store's key
455 + * list, and the dedicated manager's default settings when one owns it.
456 + * Fails open — if neither store can describe the category there is nothing
457 + * to check against, and silently dropping everything would be worse than
458 + * storing an unknown key.
459 + *
460 + * @since 2.0.1
461 + *
462 + * @param array $settings Incoming settings.
463 + * @param string $category Settings category.
464 + * @param string $context_type Context the write is scoped to.
465 + * @return array Settings limited to recognised keys.
466 + */
467 + private function filter_known_setting_keys(array $settings, string $category, string $context_type): array {
468 + $known = [];
469 +
470 + // $this->setting_categories maps category => label; the key lists live
471 + // in the generic store.
472 + $known = array_merge($known, $this->settings_manager->get_category_keys($category));
473 +
474 + if ($this->has_seo_manager($category)) {
475 + $known = array_merge(
476 + $known,
477 + array_keys($this->get_seo_manager($category)->get_default_settings($context_type))
478 + );
479 + }
480 +
481 + /**
482 + * Filter the setting keys a category accepts.
483 + *
484 + * @since 2.0.1
485 + *
486 + * @param string[] $known Recognised setting keys.
487 + * @param string $category Settings category.
488 + * @param string $context_type Context the write is scoped to.
489 + */
490 + $known = apply_filters('thinkrank_known_setting_keys', $known, $category, $context_type);
491 +
492 + if (empty($known)) {
493 + return $settings;
494 + }
495 +
496 + return array_intersect_key($settings, array_flip($known));
497 + }
498 +
499 + private function strip_masked_secrets(array $settings): array {
500 + foreach ($settings as $key => $value) {
501 + if (!in_array($key, self::SENSITIVE_SETTING_KEYS, true)) {
502 + continue;
503 + }
504 + if (is_string($value) && strpos($value, '••••') !== false) {
505 + unset($settings[$key]);
506 + }
507 + }
508 + return $settings;
509 + }
510 +
511 + /**
382 512 * Get global settings across all categories
383 513 *
384 514 * @since 1.0.0
385 515 *
@@ -398,10 +528,10 @@
398 528 if (!isset($this->setting_categories[$category])) {
399 529 continue;
400 530 }
401 531
402 - // Get settings for each category using Settings Manager
403 - $category_settings = $this->settings_manager->get_settings($category);
532 + // Get settings for each category from the store that owns it.
533 + $category_settings = $this->read_category($category);
404 534 $global_settings[$category] = $category_settings;
405 535
406 536 // Get schema if requested
407 537 if ($include_schema && $this->has_seo_manager($category)) {
@@ -468,8 +598,11 @@
468 598 "Settings for category '{$category}' must be provided as an object",
469 599 ['status' => 400]
470 600 );
471 601 }
602 +
603 + // Reads mask secrets; never persist a mask back over the real one.
604 + $settings[$category] = $this->strip_masked_secrets($category_settings);
472 605 }
473 606
474 607 $validation_results = [];
475 608 $update_results = [];
@@ -534,9 +667,9 @@
534 667 // Get updated settings
535 668 $updated_settings = [];
536 669 foreach (array_keys($settings) as $category) {
537 670 if (isset($this->setting_categories[$category])) {
538 - $updated_settings[$category] = $this->settings_manager->get_settings($category);
671 + $updated_settings[$category] = $this->read_category($category);
539 672 }
540 673 }
541 674
542 675 return new WP_REST_Response([
@@ -541,9 +674,9 @@
541 674
542 675 return new WP_REST_Response([
543 676 'success' => true,
544 677 'data' => [
545 - 'updated_settings' => $updated_settings,
678 + 'updated_settings' => $this->redact_sensitive_settings($updated_settings),
546 679 'validation_results' => $validation_results,
547 680 'update_results' => $update_results,
548 681 'settings_version' => $this->get_settings_version()
549 682 ],
@@ -581,9 +714,9 @@
581 714 );
582 715 }
583 716
584 717 // Get category settings
585 - $category_settings = $this->settings_manager->get_settings($category);
718 + $category_settings = $this->read_category($category);
586 719
587 720 // Get schema if requested
588 721 $schema = [];
589 722 if ($include_schema && $this->has_seo_manager($category)) {
@@ -601,9 +734,9 @@
601 734
602 735 return new WP_REST_Response([
603 736 'success' => true,
604 737 'data' => [
605 - 'settings' => $category_settings,
738 + 'settings' => $this->redact_category_settings($category, $category_settings),
606 739 'schema' => $schema,
607 740 'metadata' => $metadata
608 741 ],
609 742 'message' => "Settings for category '{$category}' retrieved successfully"
@@ -658,8 +791,41 @@
658 791 ['status' => 400]
659 792 );
660 793 }
661 794
795 + // SECURITY: this route also accepts an object context and forwards it
796 + // to the category's SEO manager, which upserts rows keyed by that ID.
797 + // The `thinkrank_settings` capability authorises entry to the Settings
798 + // section — it is not authorisation to edit every post on the site — so
799 + // resolve and authorise the object before ANY write happens below (#367).
800 + $context_type = $request->get_param('context_type') ?? 'site';
801 + $context_id = $request->get_param('context_id');
802 + $context_id = null === $context_id ? null : (int) $context_id;
803 +
804 + $context_error = $this->authorize_settings_context($context_type, $context_id);
805 + if (is_wp_error($context_error)) {
806 + return $context_error;
807 + }
808 +
809 + // Reads mask secrets; never persist a mask back over the real one.
810 + $settings = $this->strip_masked_secrets($settings);
811 +
812 + // Drop keys the category does not define. This route persisted any
813 + // key it was handed — a probe key written through it is still
814 + // readable in the settings table afterwards — which bloats the
815 + // store and lets a client invent settings the plugin will never
816 + // read (#395). Mirrors the same guard on the schema and
817 + // social-media routes.
818 + $settings = $this->filter_known_setting_keys($settings, $category, $context_type);
819 +
820 + if (empty($settings)) {
821 + return new WP_Error(
822 + 'invalid_settings',
823 + "No recognized settings were provided for category: {$category}",
824 + ['status' => 400]
825 + );
826 + }
827 +
662 828 $validation_result = ['valid' => true];
663 829
664 830 // Validate settings if requested
665 831 if ($validate_before_update && $this->has_seo_manager($category)) {
@@ -677,24 +843,93 @@
677 843 );
678 844 }
679 845 }
680 846
681 - // Update settings
682 - $update_success = $this->settings_manager->update_settings($settings, $category);
847 + // Update settings. The context must be forwarded: update_settings()
848 + // defaults to the 'site' context, so a post-scoped request was also
849 + // silently rewriting the site-wide defaults (#367).
850 + $generic_update = $this->settings_manager->update_settings($settings, $category, $context_type, $context_id);
851 + $manager_update = null;
683 852
684 - // Also update through specific SEO manager if available
853 + // Also update through specific SEO manager if available. The context was
854 + // resolved and authorised above.
685 855 if ($this->has_seo_manager($category)) {
686 - $context_type = $request->get_param('context_type') ?? 'site';
687 - $context_id = $request->get_param('context_id') ?? null;
688 856 $manager_update = $this->get_seo_manager($category)->save_settings($context_type, $context_id, $settings);
689 - $update_success = $update_success && $manager_update;
690 857 }
691 858
859 + // null from a store means "this category is not mine", not "the write
860 + // failed" — the two registries use different category vocabularies, so
861 + // most categories are owned by exactly one store (#371). Judge only the
862 + // stores that actually attempted a write: the save succeeded if at least
863 + // one store owned the category and none of the owners failed. ANDing the
864 + // raw values reported 500 for every category the generic store does not
865 + // know, while the dedicated manager's row had already committed.
866 + $attempted = array_filter(
867 + [$generic_update, $manager_update],
868 + static fn($result) => null !== $result
869 + );
870 +
871 + $update_success = [] !== $attempted && !in_array(false, $attempted, true);
872 +
692 873 if (!$update_success) {
874 + // Name the settings that did not persist. The write is not
875 + // transactional, so "failed" can mean some keys saved and others
876 + // did not — without the list the UI can only show a generic
877 + // error and the user has no idea what to re-enter (#300).
878 + $failed_keys = $this->settings_manager->get_last_failed_keys();
879 +
880 + // Report which store failed. Collapsing both writes into one boolean
881 + // meant a committed manager row could be reported as a total failure,
882 + // hiding a persisted change behind a 500 (#367). Only a literal false
883 + // is a failure — null means the store does not own this category and
884 + // never attempted a write, so it must not be named here (#371).
885 + $stores_failed = [];
886 + if (false === $generic_update) {
887 + $stores_failed[] = 'settings';
888 + }
889 + if (false === $manager_update) {
890 + $stores_failed[] = 'category_manager';
891 + }
892 +
893 + // No store owns the category. That is a routing defect rather than a
894 + // failed write, and it is worth distinguishing: the settings were
895 + // never persisted anywhere, so reporting it as a plain write failure
896 + // would send the user back to re-enter values that have nowhere to go.
897 + if ([] === $attempted) {
898 + return new WP_Error(
899 + 'category_not_persistable',
900 + sprintf(
901 + 'No settings store is registered for category %s, so nothing was saved.',
902 + $category
903 + ),
904 + [
905 + 'status' => 500,
906 + 'failed_keys' => $failed_keys,
907 + 'stores_failed' => $stores_failed,
908 + 'partial_write' => false,
909 + ]
910 + );
911 + }
912 +
693 913 return new WP_Error(
694 914 'update_failed',
695 - "Failed to update settings for category: {$category}",
696 - ['status' => 500]
915 + empty($failed_keys)
916 + ? "Failed to update settings for category: {$category}"
917 + : sprintf(
918 + 'Failed to save %s in category %s. Other settings in this request were saved.',
919 + implode(', ', $failed_keys),
920 + $category
921 + ),
922 + [
923 + 'status' => 500,
924 + 'failed_keys' => $failed_keys,
925 + 'stores_failed' => $stores_failed,
926 + // True when more than one store attempted the write and they
927 + // disagreed, so the client knows the request was not a clean
928 + // no-op. Stores that did not own the category are excluded.
929 + 'partial_write' => in_array(true, $attempted, true)
930 + && in_array(false, $attempted, true),
931 + ]
697 932 );
698 933 }
699 934
700 935 // Clear analytics cache when GSC/GA settings change so fresh data is fetched
@@ -709,16 +944,26 @@
709 944
710 945 // Update category metadata
711 946 $this->update_category_metadata($category);
712 947
713 - // Get updated settings
714 - $updated_settings = $this->settings_manager->get_settings($category);
948 + // Get updated settings. Read them back from whichever store actually
949 + // owns the category: the generic store returns [] for the categories it
950 + // does not know, which would report a successful save as zero settings
951 + // and hand the UI an empty form to render (#371).
952 + //
953 + // Which store *accepted the write* is the wrong question to ask here,
954 + // and `sitemap` is the case that proves it: the generic store claims
955 + // that write (update_settings() returns true, not null) and then reads
956 + // the category back as [], so keying off $generic_update sent the one
957 + // read path that had been fixed straight back into the empty store.
958 + // Ownership is a property of the category, not of the last write (#689).
959 + $updated_settings = $this->read_category($category, $context_type, $context_id);
715 960
716 961 return new WP_REST_Response([
717 962 'success' => true,
718 963 'data' => [
719 964 'category' => $category,
720 - 'updated_settings' => $updated_settings,
965 + 'updated_settings' => $this->redact_category_settings($category, $updated_settings),
721 966 'validation_result' => $validation_result,
722 967 'settings_count' => count($updated_settings)
723 968 ],
724 969 'message' => "Settings for category '{$category}' updated successfully"
@@ -889,9 +1134,9 @@
889 1134 if (!isset($this->setting_categories[$category])) {
890 1135 continue;
891 1136 }
892 1137
893 - $export_data[$category] = $this->settings_manager->get_settings($category);
1138 + $export_data[$category] = $this->read_category($category);
894 1139 }
895 1140
896 1141 // Never let secrets (API keys, OAuth tokens) leave the site in an
897 1142 // export file — strip them entirely.
@@ -1027,9 +1272,9 @@
1027 1272 }
1028 1273
1029 1274 try {
1030 1275 // Check if settings exist and handle overwrite
1031 - $existing_settings = $this->settings_manager->get_settings($category);
1276 + $existing_settings = $this->read_category($category);
1032 1277
1033 1278 if (!empty($existing_settings) && !$overwrite_existing) {
1034 1279 $import_results[$category] = [
1035 1280 'success' => false,
@@ -1102,9 +1347,9 @@
1102 1347 // Create backup data
1103 1348 $backup_data = [];
1104 1349 foreach ($categories as $category) {
1105 1350 if (isset($this->setting_categories[$category])) {
1106 - $backup_data[$category] = $this->settings_manager->get_settings($category);
1351 + $backup_data[$category] = $this->read_category($category);
1107 1352 }
1108 1353 }
1109 1354
1110 1355 // Create backup metadata
@@ -1355,9 +1600,9 @@
1355 1600 *
1356 1601 * @param WP_REST_Request $request Request object
1357 1602 * @return WP_REST_Response|WP_Error Response object
1358 1603 */
1359 - public function add_performance_indexes(WP_REST_Request $request): WP_REST_Response|WP_Error {
1604 + public function add_performance_indexes(WP_REST_Request $request) {
1360 1605 try {
1361 1606 // Import the Database_Schema class
1362 1607 if (!class_exists('ThinkRank\\Database\\Database_Schema')) {
1363 1608 require_once THINKRANK_PLUGIN_DIR . 'includes/database/class-database-schema.php';
@@ -1405,15 +1650,49 @@
1405 1650 * @since 1.0.0
1406 1651 *
1407 1652 * @return bool Permission status
1408 1653 */
1409 - public function check_read_permissions(): bool {
1654 + public function check_read_permissions(WP_REST_Request $request): bool {
1410 1655 // Plugin SEO/AI config is not subscriber-visible — require the same
1411 - // management capability as the write routes.
1412 - return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
1656 + // management capability as the write routes, resolved per category so a
1657 + // role granted one section can reach that section and no other (#573).
1658 + return \ThinkRank\Core\Capability_Manager::current_user_can(
1659 + $this->capability_for_request($request)
1660 + );
1413 1661 }
1414 1662
1415 1663 /**
1664 + * The capability a settings-management request requires.
1665 + *
1666 + * Category routes belong to the section owning the category; every other
1667 + * route on this controller is plugin-wide configuration and stays on
1668 + * `thinkrank_settings`. The gate in Role_Manager::gate_rest() reaches the
1669 + * same answer through Capability_Manager::capability_for_route() — both are
1670 + * kept so neither layer alone is load-bearing.
1671 + *
1672 + * @since 2.1.3
1673 + *
1674 + * @param WP_REST_Request $request Request.
1675 + * @return string
1676 + */
1677 + private function capability_for_request(WP_REST_Request $request): string {
1678 + // URL params only. get_param() searches the JSON body, the POST body
1679 + // and the query string ahead of the route path, so on the routes that
1680 + // declare no {category} — /global, /validate, /schema, /export,
1681 + // /backup, /restore — it read pure caller input and let a request
1682 + // nominate the capability it would be checked against (#582). Reading
1683 + // the path is also what Role_Manager::gate_rest() does, so the two
1684 + // layers now agree and the claim above is true again.
1685 + $category = $request->get_url_params()['category'] ?? null;
1686 +
1687 + if (!is_string($category) || '' === $category) {
1688 + return 'thinkrank_settings';
1689 + }
1690 +
1691 + return \ThinkRank\Core\Capability_Manager::capability_for_settings_category($category);
1692 + }
1693 +
1694 + /**
1416 1695 * Check permissions for managing settings
1417 1696 *
1418 1697 * @since 1.0.0
1419 1698 *
@@ -1418,13 +1697,31 @@
1418 1697 * @since 1.0.0
1419 1698 *
1420 1699 * @return bool Permission status
1421 1700 */
1422 - public function check_manage_permissions(): bool {
1423 - return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
1701 + public function check_manage_permissions(WP_REST_Request $request): bool {
1702 + return \ThinkRank\Core\Capability_Manager::current_user_can(
1703 + $this->capability_for_request($request)
1704 + );
1424 1705 }
1425 1706
1426 1707 /**
1708 + * Check permissions for administrator-only settings operations.
1709 + *
1710 + * The Role Manager can delegate `thinkrank_settings` to non-admin roles so
1711 + * they can manage the plugin's SEO configuration. Schema-level (DDL) and
1712 + * destructive whole-configuration operations — performance indexes, reset,
1713 + * import — are a different altitude and stay with site administrators.
1714 + *
1715 + * @since 1.29.0
1716 + *
1717 + * @return bool Permission status
1718 + */
1719 + public function check_admin_permissions(): bool {
1720 + return current_user_can('manage_options');
1721 + }
1722 +
1723 + /**
1427 1724 * Helper methods
1428 1725 */
1429 1726
1430 1727 /**
@@ -1616,8 +1913,9 @@
1616 1913 uasort($backup_index, static function ($a, $b) {
1617 1914 return strcmp((string) ($a['created_at'] ?? ''), (string) ($b['created_at'] ?? ''));
1618 1915 });
1619 1916
1917 + // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found -- the loop shrinks $backup_index, so the count has to be re-read.
1620 1918 while (count($backup_index) > $max_backups) {
1621 1919 $oldest_id = array_key_first($backup_index);
1622 1920 unset($backup_index[$oldest_id]);
1623 1921 delete_option("thinkrank_backup_{$oldest_id}");
@@ -1683,13 +1981,100 @@
1683 1981 'required' => false,
1684 1982 'type' => 'boolean',
1685 1983 'default' => true,
1686 1984 'description' => 'Whether to validate settings before updating'
1985 + ],
1986 + // Declared so the REST schema validates/normalises them. They were read
1987 + // by the handler while undeclared, which skipped validation entirely (#367).
1988 + 'context_type' => [
1989 + 'required' => false,
1990 + 'type' => 'string',
1991 + 'enum' => ['site', 'post', 'page', 'product'],
1992 + 'default' => 'site',
1993 + 'description' => 'Object context these settings apply to'
1994 + ],
1995 + 'context_id' => [
1996 + 'required' => false,
1997 + 'type' => 'integer',
1998 + 'minimum' => 1,
1999 + 'description' => 'Object ID when context_type is not "site"'
1687 2000 ]
1688 2001 ];
1689 2002 }
1690 2003
1691 2004 /**
2005 + * Authorise the object context a category settings write targets.
2006 + *
2007 + * The Settings section capability is delegatable, so a non-administrator can
2008 + * reach this controller. Writing settings for a specific post is an edit of
2009 + * that post and must be authorised as one — mirroring the per-object check the
2010 + * social-media write route performs (#277, #367).
2011 + *
2012 + * @since 1.32.0
2013 + *
2014 + * @param string $context_type Requested context type.
2015 + * @param int|null $context_id Requested object ID.
2016 + * @return true|WP_Error True when the write is allowed, WP_Error otherwise.
2017 + */
2018 + private function authorize_settings_context(string $context_type, ?int $context_id) {
2019 + if ('site' === $context_type) {
2020 + return true;
2021 + }
2022 +
2023 + if (!in_array($context_type, ['post', 'page', 'product'], true)) {
2024 + return new WP_Error(
2025 + 'invalid_context',
2026 + 'Invalid context type provided',
2027 + ['status' => 400]
2028 + );
2029 + }
2030 +
2031 + if (!$context_id || $context_id <= 0) {
2032 + return new WP_Error(
2033 + 'invalid_context',
2034 + 'A valid context_id is required for non-site contexts',
2035 + ['status' => 400]
2036 + );
2037 + }
2038 +
2039 + $post = get_post($context_id);
2040 +
2041 + if (!$post || 'revision' === $post->post_type) {
2042 + return new WP_Error(
2043 + 'invalid_context',
2044 + 'The requested content could not be found',
2045 + ['status' => 404]
2046 + );
2047 + }
2048 +
2049 + // The declared context must match the one the front-end read path derives
2050 + // from the real post type, otherwise `page`/`product` can alias an arbitrary
2051 + // object and the row is written where nothing will ever read it. Mirrors
2052 + // Seo_Manager::get_context_type() — custom post types fall back to 'post'.
2053 + $expected_context = in_array($post->post_type, ['post', 'page', 'product'], true)
2054 + ? $post->post_type
2055 + : 'post';
2056 +
2057 + if ($context_type !== $expected_context) {
2058 + return new WP_Error(
2059 + 'invalid_context',
2060 + 'The context type does not match the requested content.',
2061 + ['status' => 400]
2062 + );
2063 + }
2064 +
2065 + if (!current_user_can('edit_post', $context_id)) {
2066 + return new WP_Error(
2067 + 'rest_forbidden',
2068 + 'You are not allowed to edit settings for this content.',
2069 + ['status' => 403]
2070 + );
2071 + }
2072 +
2073 + return true;
2074 + }
2075 +
2076 + /**
1692 2077 * Get arguments for validation endpoint
1693 2078 *
1694 2079 * @since 1.0.0
1695 2080 *
@@ -1888,9 +2273,27 @@
1888 2273 private function create_settings_snapshot(array $categories, string $label): string {
1889 2274 $backup_data = [];
1890 2275 foreach ($categories as $category) {
1891 2276 if (isset($this->setting_categories[$category])) {
1892 - $backup_data[$category] = $this->settings_manager->get_settings($category);
2277 + $backup_data[$category] = $this->read_category($category);
2278 + }
2279 + }
2280 +
2281 + // A snapshot that captured nothing for a category that does hold settings
2282 + // is worse than no snapshot: reset checks only that an id came back, so an
2283 + // empty one is accepted as a rollback point and the defaults go over live
2284 + // data that can no longer be recovered. That is exactly what #689 was.
2285 + //
2286 + // Ask the owning manager directly rather than trusting read_category(),
2287 + // so this stays a real check if a future edit sends a read back to the
2288 + // wrong store instead of quietly agreeing with it.
2289 + foreach ($backup_data as $category => $captured) {
2290 + if (!empty($captured) || !$this->has_seo_manager($category)) {
2291 + continue;
2292 + }
2293 +
2294 + if (!empty((array) $this->get_seo_manager($category)->get_settings('site', null))) {
2295 + return '';
1893 2296 }
1894 2297 }
1895 2298
1896 2299 $backup_metadata = [