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 +107 -16 2.1.1 → 2.10.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 */
@@ -331,8 +362,9 @@
331 362 'openai_api_key',
332 363 'claude_api_key',
333 364 'gemini_api_key',
334 365 'openrouter_api_key',
366 + 'openai_compatible_api_key',
335 367 'google_analytics_api_key',
336 368 'google_search_console_api_key',
337 369 'google_pagespeed_api_key',
338 370 'google_access_token',
@@ -496,10 +528,10 @@
496 528 if (!isset($this->setting_categories[$category])) {
497 529 continue;
498 530 }
499 531
500 - // Get settings for each category using Settings Manager
501 - $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);
502 534 $global_settings[$category] = $category_settings;
503 535
504 536 // Get schema if requested
505 537 if ($include_schema && $this->has_seo_manager($category)) {
@@ -635,9 +667,9 @@
635 667 // Get updated settings
636 668 $updated_settings = [];
637 669 foreach (array_keys($settings) as $category) {
638 670 if (isset($this->setting_categories[$category])) {
639 - $updated_settings[$category] = $this->settings_manager->get_settings($category);
671 + $updated_settings[$category] = $this->read_category($category);
640 672 }
641 673 }
642 674
643 675 return new WP_REST_Response([
@@ -682,9 +714,9 @@
682 714 );
683 715 }
684 716
685 717 // Get category settings
686 - $category_settings = $this->settings_manager->get_settings($category);
718 + $category_settings = $this->read_category($category);
687 719
688 720 // Get schema if requested
689 721 $schema = [];
690 722 if ($include_schema && $this->has_seo_manager($category)) {
@@ -916,11 +948,16 @@
916 948 // Get updated settings. Read them back from whichever store actually
917 949 // owns the category: the generic store returns [] for the categories it
918 950 // does not know, which would report a successful save as zero settings
919 951 // and hand the UI an empty form to render (#371).
920 - $updated_settings = null === $generic_update && $this->has_seo_manager($category)
921 - ? $this->get_seo_manager($category)->get_settings($context_type, $context_id)
922 - : $this->settings_manager->get_settings($category, $context_type, $context_id);
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);
923 960
924 961 return new WP_REST_Response([
925 962 'success' => true,
926 963 'data' => [
@@ -1097,9 +1134,9 @@
1097 1134 if (!isset($this->setting_categories[$category])) {
1098 1135 continue;
1099 1136 }
1100 1137
1101 - $export_data[$category] = $this->settings_manager->get_settings($category);
1138 + $export_data[$category] = $this->read_category($category);
1102 1139 }
1103 1140
1104 1141 // Never let secrets (API keys, OAuth tokens) leave the site in an
1105 1142 // export file — strip them entirely.
@@ -1235,9 +1272,9 @@
1235 1272 }
1236 1273
1237 1274 try {
1238 1275 // Check if settings exist and handle overwrite
1239 - $existing_settings = $this->settings_manager->get_settings($category);
1276 + $existing_settings = $this->read_category($category);
1240 1277
1241 1278 if (!empty($existing_settings) && !$overwrite_existing) {
1242 1279 $import_results[$category] = [
1243 1280 'success' => false,
@@ -1310,9 +1347,9 @@
1310 1347 // Create backup data
1311 1348 $backup_data = [];
1312 1349 foreach ($categories as $category) {
1313 1350 if (isset($this->setting_categories[$category])) {
1314 - $backup_data[$category] = $this->settings_manager->get_settings($category);
1351 + $backup_data[$category] = $this->read_category($category);
1315 1352 }
1316 1353 }
1317 1354
1318 1355 // Create backup metadata
@@ -1613,15 +1650,49 @@
1613 1650 * @since 1.0.0
1614 1651 *
1615 1652 * @return bool Permission status
1616 1653 */
1617 - public function check_read_permissions(): bool {
1654 + public function check_read_permissions(WP_REST_Request $request): bool {
1618 1655 // Plugin SEO/AI config is not subscriber-visible — require the same
1619 - // management capability as the write routes.
1620 - 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 + );
1621 1661 }
1622 1662
1623 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 + /**
1624 1695 * Check permissions for managing settings
1625 1696 *
1626 1697 * @since 1.0.0
1627 1698 *
@@ -1626,10 +1697,12 @@
1626 1697 * @since 1.0.0
1627 1698 *
1628 1699 * @return bool Permission status
1629 1700 */
1630 - public function check_manage_permissions(): bool {
1631 - 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 + );
1632 1705 }
1633 1706
1634 1707 /**
1635 1708 * Check permissions for administrator-only settings operations.
@@ -2200,9 +2273,27 @@
2200 2273 private function create_settings_snapshot(array $categories, string $label): string {
2201 2274 $backup_data = [];
2202 2275 foreach ($categories as $category) {
2203 2276 if (isset($this->setting_categories[$category])) {
2204 - $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 '';
2205 2296 }
2206 2297 }
2207 2298
2208 2299 $backup_metadata = [