PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
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-settings-management-endpoint.php +111 -18 2.1.1 → 2.14.2 View file →
@@ -23,8 +23,9 @@
23 23 use ThinkRank\SEO\Content_Optimization_Manager;
24 24 use ThinkRank\SEO\Schema_Management_System;
25 25 use ThinkRank\SEO\Social_Meta_Manager;
26 26 use ThinkRank\SEO\Sitemap_Generator;
27 +use ThinkRank\SEO\Analytics_Manager;
27 28 use WP_REST_Controller;
28 29 use WP_REST_Request;
29 30 use WP_REST_Response;
30 31 use WP_Error;
@@ -159,8 +160,39 @@
159 160 return $this->seo_managers[$category];
160 161 }
161 162
162 163 /**
164 + * Read a category from whichever store actually owns it.
165 + *
166 + * The generic store returns `[]` for the eight SEO categories: it looks for
167 + * rows whose key carries a `<category>_` prefix, and the rows carry no such
168 + * prefix — `social_media` is stored as `social_meta`, `schema_management` as
169 + * `schema_management_system`, and their keys are bare (`og_site_name`). So a
170 + * direct `Settings_Manager::get_settings()` reports a configured site as
171 + * having no settings at all.
172 + *
173 + * Writes never had the problem, because the write path already falls back to
174 + * the owning manager. That asymmetry is what made this invisible from the UI
175 + * and dangerous underneath it: the pre-reset rollback snapshotted `[]` and
176 + * then defaults were written over live settings, so Reset could not be undone
177 + * (#689). Every read goes through here now, so there is one place to be wrong.
178 + *
179 + * @since 2.7.0
180 + *
181 + * @param string $category Category key.
182 + * @param string $context_type Optional. Context type. Default 'site'.
183 + * @param int|null $context_id Optional. Context ID.
184 + * @return array The category's stored settings.
185 + */
186 + private function read_category(string $category, string $context_type = 'site', ?int $context_id = null): array {
187 + if ($this->has_seo_manager($category)) {
188 + return (array) $this->get_seo_manager($category)->get_settings($context_type, $context_id);
189 + }
190 +
191 + return (array) $this->settings_manager->get_settings($category, $context_type, $context_id);
192 + }
193 +
194 + /**
163 195 * Register API routes
164 196 *
165 197 * @since 1.0.0
166 198 */
@@ -331,8 +363,9 @@
331 363 'openai_api_key',
332 364 'claude_api_key',
333 365 'gemini_api_key',
334 366 'openrouter_api_key',
367 + 'openai_compatible_api_key',
335 368 'google_analytics_api_key',
336 369 'google_search_console_api_key',
337 370 'google_pagespeed_api_key',
338 371 'google_access_token',
@@ -496,10 +529,10 @@
496 529 if (!isset($this->setting_categories[$category])) {
497 530 continue;
498 531 }
499 532
500 - // Get settings for each category using Settings Manager
501 - $category_settings = $this->settings_manager->get_settings($category);
533 + // Get settings for each category from the store that owns it.
534 + $category_settings = $this->read_category($category);
502 535 $global_settings[$category] = $category_settings;
503 536
504 537 // Get schema if requested
505 538 if ($include_schema && $this->has_seo_manager($category)) {
@@ -635,9 +668,9 @@
635 668 // Get updated settings
636 669 $updated_settings = [];
637 670 foreach (array_keys($settings) as $category) {
638 671 if (isset($this->setting_categories[$category])) {
639 - $updated_settings[$category] = $this->settings_manager->get_settings($category);
672 + $updated_settings[$category] = $this->read_category($category);
640 673 }
641 674 }
642 675
643 676 return new WP_REST_Response([
@@ -682,9 +715,9 @@
682 715 );
683 716 }
684 717
685 718 // Get category settings
686 - $category_settings = $this->settings_manager->get_settings($category);
719 + $category_settings = $this->read_category($category);
687 720
688 721 // Get schema if requested
689 722 $schema = [];
690 723 if ($include_schema && $this->has_seo_manager($category)) {
@@ -901,11 +934,12 @@
901 934 }
902 935
903 936 // Clear analytics cache when GSC/GA settings change so fresh data is fetched
904 937 if ($category === 'seo_analytics') {
938 + foreach (Analytics_Manager::dashboard_cache_keys() as $cache_key) {
939 + delete_transient($cache_key);
940 + }
905 941 foreach (['7d', '30d', '90d'] as $range) {
906 - delete_transient("analytics_dashboard_v5_{$range}");
907 - delete_transient("seo_opportunities_{$range}");
908 942 delete_transient("seo_insights_{$range}");
909 943 }
910 944 delete_transient('indexing_status');
911 945 }
@@ -916,11 +950,16 @@
916 950 // Get updated settings. Read them back from whichever store actually
917 951 // owns the category: the generic store returns [] for the categories it
918 952 // does not know, which would report a successful save as zero settings
919 953 // 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);
954 + //
955 + // Which store *accepted the write* is the wrong question to ask here,
956 + // and `sitemap` is the case that proves it: the generic store claims
957 + // that write (update_settings() returns true, not null) and then reads
958 + // the category back as [], so keying off $generic_update sent the one
959 + // read path that had been fixed straight back into the empty store.
960 + // Ownership is a property of the category, not of the last write (#689).
961 + $updated_settings = $this->read_category($category, $context_type, $context_id);
923 962
924 963 return new WP_REST_Response([
925 964 'success' => true,
926 965 'data' => [
@@ -1097,9 +1136,9 @@
1097 1136 if (!isset($this->setting_categories[$category])) {
1098 1137 continue;
1099 1138 }
1100 1139
1101 - $export_data[$category] = $this->settings_manager->get_settings($category);
1140 + $export_data[$category] = $this->read_category($category);
1102 1141 }
1103 1142
1104 1143 // Never let secrets (API keys, OAuth tokens) leave the site in an
1105 1144 // export file — strip them entirely.
@@ -1235,9 +1274,9 @@
1235 1274 }
1236 1275
1237 1276 try {
1238 1277 // Check if settings exist and handle overwrite
1239 - $existing_settings = $this->settings_manager->get_settings($category);
1278 + $existing_settings = $this->read_category($category);
1240 1279
1241 1280 if (!empty($existing_settings) && !$overwrite_existing) {
1242 1281 $import_results[$category] = [
1243 1282 'success' => false,
@@ -1310,9 +1349,9 @@
1310 1349 // Create backup data
1311 1350 $backup_data = [];
1312 1351 foreach ($categories as $category) {
1313 1352 if (isset($this->setting_categories[$category])) {
1314 - $backup_data[$category] = $this->settings_manager->get_settings($category);
1353 + $backup_data[$category] = $this->read_category($category);
1315 1354 }
1316 1355 }
1317 1356
1318 1357 // Create backup metadata
@@ -1613,15 +1652,49 @@
1613 1652 * @since 1.0.0
1614 1653 *
1615 1654 * @return bool Permission status
1616 1655 */
1617 - public function check_read_permissions(): bool {
1656 + public function check_read_permissions(WP_REST_Request $request): bool {
1618 1657 // 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');
1658 + // management capability as the write routes, resolved per category so a
1659 + // role granted one section can reach that section and no other (#573).
1660 + return \ThinkRank\Core\Capability_Manager::current_user_can(
1661 + $this->capability_for_request($request)
1662 + );
1621 1663 }
1622 1664
1623 1665 /**
1666 + * The capability a settings-management request requires.
1667 + *
1668 + * Category routes belong to the section owning the category; every other
1669 + * route on this controller is plugin-wide configuration and stays on
1670 + * `thinkrank_settings`. The gate in Role_Manager::gate_rest() reaches the
1671 + * same answer through Capability_Manager::capability_for_route() — both are
1672 + * kept so neither layer alone is load-bearing.
1673 + *
1674 + * @since 2.1.3
1675 + *
1676 + * @param WP_REST_Request $request Request.
1677 + * @return string
1678 + */
1679 + private function capability_for_request(WP_REST_Request $request): string {
1680 + // URL params only. get_param() searches the JSON body, the POST body
1681 + // and the query string ahead of the route path, so on the routes that
1682 + // declare no {category} — /global, /validate, /schema, /export,
1683 + // /backup, /restore — it read pure caller input and let a request
1684 + // nominate the capability it would be checked against (#582). Reading
1685 + // the path is also what Role_Manager::gate_rest() does, so the two
1686 + // layers now agree and the claim above is true again.
1687 + $category = $request->get_url_params()['category'] ?? null;
1688 +
1689 + if (!is_string($category) || '' === $category) {
1690 + return 'thinkrank_settings';
1691 + }
1692 +
1693 + return \ThinkRank\Core\Capability_Manager::capability_for_settings_category($category);
1694 + }
1695 +
1696 + /**
1624 1697 * Check permissions for managing settings
1625 1698 *
1626 1699 * @since 1.0.0
1627 1700 *
@@ -1626,10 +1699,12 @@
1626 1699 * @since 1.0.0
1627 1700 *
1628 1701 * @return bool Permission status
1629 1702 */
1630 - public function check_manage_permissions(): bool {
1631 - return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
1703 + public function check_manage_permissions(WP_REST_Request $request): bool {
1704 + return \ThinkRank\Core\Capability_Manager::current_user_can(
1705 + $this->capability_for_request($request)
1706 + );
1632 1707 }
1633 1708
1634 1709 /**
1635 1710 * Check permissions for administrator-only settings operations.
@@ -2200,9 +2275,27 @@
2200 2275 private function create_settings_snapshot(array $categories, string $label): string {
2201 2276 $backup_data = [];
2202 2277 foreach ($categories as $category) {
2203 2278 if (isset($this->setting_categories[$category])) {
2204 - $backup_data[$category] = $this->settings_manager->get_settings($category);
2279 + $backup_data[$category] = $this->read_category($category);
2280 + }
2281 + }
2282 +
2283 + // A snapshot that captured nothing for a category that does hold settings
2284 + // is worse than no snapshot: reset checks only that an id came back, so an
2285 + // empty one is accepted as a rollback point and the defaults go over live
2286 + // data that can no longer be recovered. That is exactly what #689 was.
2287 + //
2288 + // Ask the owning manager directly rather than trusting read_category(),
2289 + // so this stays a real check if a future edit sends a read back to the
2290 + // wrong store instead of quietly agreeing with it.
2291 + foreach ($backup_data as $category => $captured) {
2292 + if (!empty($captured) || !$this->has_seo_manager($category)) {
2293 + continue;
2294 + }
2295 +
2296 + if (!empty((array) $this->get_seo_manager($category)->get_settings('site', null))) {
2297 + return '';
2205 2298 }
2206 2299 }
2207 2300
2208 2301 $backup_metadata = [