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-settings-management-endpoint.php +170 -16 1.32.02.7.0 View file →
@@ -159,8 +159,39 @@
159 159 return $this->seo_managers[$category];
160 160 }
161 161
162 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 + /**
163 194 * Register API routes
164 195 *
165 196 * @since 1.0.0
166 197 */
@@ -415,8 +446,56 @@
415 446 *
416 447 * @param array $settings Flat key => value map from the request.
417 448 * @return array Map with masked secret values removed.
418 449 */
450 + /**
451 + * Drop setting keys the category does not define.
452 + *
453 + * The known set is whatever describes the category: the generic store's key
454 + * list, and the dedicated manager's default settings when one owns it.
455 + * Fails open — if neither store can describe the category there is nothing
456 + * to check against, and silently dropping everything would be worse than
457 + * storing an unknown key.
458 + *
459 + * @since 2.0.1
460 + *
461 + * @param array $settings Incoming settings.
462 + * @param string $category Settings category.
463 + * @param string $context_type Context the write is scoped to.
464 + * @return array Settings limited to recognised keys.
465 + */
466 + private function filter_known_setting_keys(array $settings, string $category, string $context_type): array {
467 + $known = [];
468 +
469 + // $this->setting_categories maps category => label; the key lists live
470 + // in the generic store.
471 + $known = array_merge($known, $this->settings_manager->get_category_keys($category));
472 +
473 + if ($this->has_seo_manager($category)) {
474 + $known = array_merge(
475 + $known,
476 + array_keys($this->get_seo_manager($category)->get_default_settings($context_type))
477 + );
478 + }
479 +
480 + /**
481 + * Filter the setting keys a category accepts.
482 + *
483 + * @since 2.0.1
484 + *
485 + * @param string[] $known Recognised setting keys.
486 + * @param string $category Settings category.
487 + * @param string $context_type Context the write is scoped to.
488 + */
489 + $known = apply_filters('thinkrank_known_setting_keys', $known, $category, $context_type);
490 +
491 + if (empty($known)) {
492 + return $settings;
493 + }
494 +
495 + return array_intersect_key($settings, array_flip($known));
496 + }
497 +
419 498 private function strip_masked_secrets(array $settings): array {
420 499 foreach ($settings as $key => $value) {
421 500 if (!in_array($key, self::SENSITIVE_SETTING_KEYS, true)) {
422 501 continue;
@@ -448,10 +527,10 @@
448 527 if (!isset($this->setting_categories[$category])) {
449 528 continue;
450 529 }
451 530
452 - // Get settings for each category using Settings Manager
453 - $category_settings = $this->settings_manager->get_settings($category);
531 + // Get settings for each category from the store that owns it.
532 + $category_settings = $this->read_category($category);
454 533 $global_settings[$category] = $category_settings;
455 534
456 535 // Get schema if requested
457 536 if ($include_schema && $this->has_seo_manager($category)) {
@@ -587,9 +666,9 @@
587 666 // Get updated settings
588 667 $updated_settings = [];
589 668 foreach (array_keys($settings) as $category) {
590 669 if (isset($this->setting_categories[$category])) {
591 - $updated_settings[$category] = $this->settings_manager->get_settings($category);
670 + $updated_settings[$category] = $this->read_category($category);
592 671 }
593 672 }
594 673
595 674 return new WP_REST_Response([
@@ -634,9 +713,9 @@
634 713 );
635 714 }
636 715
637 716 // Get category settings
638 - $category_settings = $this->settings_manager->get_settings($category);
717 + $category_settings = $this->read_category($category);
639 718
640 719 // Get schema if requested
641 720 $schema = [];
642 721 if ($include_schema && $this->has_seo_manager($category)) {
@@ -728,8 +807,24 @@
728 807
729 808 // Reads mask secrets; never persist a mask back over the real one.
730 809 $settings = $this->strip_masked_secrets($settings);
731 810
811 + // Drop keys the category does not define. This route persisted any
812 + // key it was handed — a probe key written through it is still
813 + // readable in the settings table afterwards — which bloats the
814 + // store and lets a client invent settings the plugin will never
815 + // read (#395). Mirrors the same guard on the schema and
816 + // social-media routes.
817 + $settings = $this->filter_known_setting_keys($settings, $category, $context_type);
818 +
819 + if (empty($settings)) {
820 + return new WP_Error(
821 + 'invalid_settings',
822 + "No recognized settings were provided for category: {$category}",
823 + ['status' => 400]
824 + );
825 + }
826 +
732 827 $validation_result = ['valid' => true];
733 828
734 829 // Validate settings if requested
735 830 if ($validate_before_update && $this->has_seo_manager($category)) {
@@ -852,11 +947,16 @@
852 947 // Get updated settings. Read them back from whichever store actually
853 948 // owns the category: the generic store returns [] for the categories it
854 949 // does not know, which would report a successful save as zero settings
855 950 // and hand the UI an empty form to render (#371).
856 - $updated_settings = null === $generic_update && $this->has_seo_manager($category)
857 - ? $this->get_seo_manager($category)->get_settings($context_type, $context_id)
858 - : $this->settings_manager->get_settings($category, $context_type, $context_id);
951 + //
952 + // Which store *accepted the write* is the wrong question to ask here,
953 + // and `sitemap` is the case that proves it: the generic store claims
954 + // that write (update_settings() returns true, not null) and then reads
955 + // the category back as [], so keying off $generic_update sent the one
956 + // read path that had been fixed straight back into the empty store.
957 + // Ownership is a property of the category, not of the last write (#689).
958 + $updated_settings = $this->read_category($category, $context_type, $context_id);
859 959
860 960 return new WP_REST_Response([
861 961 'success' => true,
862 962 'data' => [
@@ -1033,9 +1133,9 @@
1033 1133 if (!isset($this->setting_categories[$category])) {
1034 1134 continue;
1035 1135 }
1036 1136
1037 - $export_data[$category] = $this->settings_manager->get_settings($category);
1137 + $export_data[$category] = $this->read_category($category);
1038 1138 }
1039 1139
1040 1140 // Never let secrets (API keys, OAuth tokens) leave the site in an
1041 1141 // export file — strip them entirely.
@@ -1171,9 +1271,9 @@
1171 1271 }
1172 1272
1173 1273 try {
1174 1274 // Check if settings exist and handle overwrite
1175 - $existing_settings = $this->settings_manager->get_settings($category);
1275 + $existing_settings = $this->read_category($category);
1176 1276
1177 1277 if (!empty($existing_settings) && !$overwrite_existing) {
1178 1278 $import_results[$category] = [
1179 1279 'success' => false,
@@ -1246,9 +1346,9 @@
1246 1346 // Create backup data
1247 1347 $backup_data = [];
1248 1348 foreach ($categories as $category) {
1249 1349 if (isset($this->setting_categories[$category])) {
1250 - $backup_data[$category] = $this->settings_manager->get_settings($category);
1350 + $backup_data[$category] = $this->read_category($category);
1251 1351 }
1252 1352 }
1253 1353
1254 1354 // Create backup metadata
@@ -1549,15 +1649,49 @@
1549 1649 * @since 1.0.0
1550 1650 *
1551 1651 * @return bool Permission status
1552 1652 */
1553 - public function check_read_permissions(): bool {
1653 + public function check_read_permissions(WP_REST_Request $request): bool {
1554 1654 // Plugin SEO/AI config is not subscriber-visible — require the same
1555 - // management capability as the write routes.
1556 - return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
1655 + // management capability as the write routes, resolved per category so a
1656 + // role granted one section can reach that section and no other (#573).
1657 + return \ThinkRank\Core\Capability_Manager::current_user_can(
1658 + $this->capability_for_request($request)
1659 + );
1557 1660 }
1558 1661
1559 1662 /**
1663 + * The capability a settings-management request requires.
1664 + *
1665 + * Category routes belong to the section owning the category; every other
1666 + * route on this controller is plugin-wide configuration and stays on
1667 + * `thinkrank_settings`. The gate in Role_Manager::gate_rest() reaches the
1668 + * same answer through Capability_Manager::capability_for_route() — both are
1669 + * kept so neither layer alone is load-bearing.
1670 + *
1671 + * @since 2.1.3
1672 + *
1673 + * @param WP_REST_Request $request Request.
1674 + * @return string
1675 + */
1676 + private function capability_for_request(WP_REST_Request $request): string {
1677 + // URL params only. get_param() searches the JSON body, the POST body
1678 + // and the query string ahead of the route path, so on the routes that
1679 + // declare no {category} — /global, /validate, /schema, /export,
1680 + // /backup, /restore — it read pure caller input and let a request
1681 + // nominate the capability it would be checked against (#582). Reading
1682 + // the path is also what Role_Manager::gate_rest() does, so the two
1683 + // layers now agree and the claim above is true again.
1684 + $category = $request->get_url_params()['category'] ?? null;
1685 +
1686 + if (!is_string($category) || '' === $category) {
1687 + return 'thinkrank_settings';
1688 + }
1689 +
1690 + return \ThinkRank\Core\Capability_Manager::capability_for_settings_category($category);
1691 + }
1692 +
1693 + /**
1560 1694 * Check permissions for managing settings
1561 1695 *
1562 1696 * @since 1.0.0
1563 1697 *
@@ -1562,10 +1696,12 @@
1562 1696 * @since 1.0.0
1563 1697 *
1564 1698 * @return bool Permission status
1565 1699 */
1566 - public function check_manage_permissions(): bool {
1567 - return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
1700 + public function check_manage_permissions(WP_REST_Request $request): bool {
1701 + return \ThinkRank\Core\Capability_Manager::current_user_can(
1702 + $this->capability_for_request($request)
1703 + );
1568 1704 }
1569 1705
1570 1706 /**
1571 1707 * Check permissions for administrator-only settings operations.
@@ -2136,9 +2272,27 @@
2136 2272 private function create_settings_snapshot(array $categories, string $label): string {
2137 2273 $backup_data = [];
2138 2274 foreach ($categories as $category) {
2139 2275 if (isset($this->setting_categories[$category])) {
2140 - $backup_data[$category] = $this->settings_manager->get_settings($category);
2276 + $backup_data[$category] = $this->read_category($category);
2277 + }
2278 + }
2279 +
2280 + // A snapshot that captured nothing for a category that does hold settings
2281 + // is worse than no snapshot: reset checks only that an id came back, so an
2282 + // empty one is accepted as a rollback point and the defaults go over live
2283 + // data that can no longer be recovered. That is exactly what #689 was.
2284 + //
2285 + // Ask the owning manager directly rather than trusting read_category(),
2286 + // so this stays a real check if a future edit sends a read back to the
2287 + // wrong store instead of quietly agreeing with it.
2288 + foreach ($backup_data as $category => $captured) {
2289 + if (!empty($captured) || !$this->has_seo_manager($category)) {
2290 + continue;
2291 + }
2292 +
2293 + if (!empty((array) $this->get_seo_manager($category)->get_settings('site', null))) {
2294 + return '';
2141 2295 }
2142 2296 }
2143 2297
2144 2298 $backup_metadata = [