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 +175 -18 2.0.0 → 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',
@@ -415,8 +448,56 @@
415 448 *
416 449 * @param array $settings Flat key => value map from the request.
417 450 * @return array Map with masked secret values removed.
418 451 */
452 + /**
453 + * Drop setting keys the category does not define.
454 + *
455 + * The known set is whatever describes the category: the generic store's key
456 + * list, and the dedicated manager's default settings when one owns it.
457 + * Fails open — if neither store can describe the category there is nothing
458 + * to check against, and silently dropping everything would be worse than
459 + * storing an unknown key.
460 + *
461 + * @since 2.0.1
462 + *
463 + * @param array $settings Incoming settings.
464 + * @param string $category Settings category.
465 + * @param string $context_type Context the write is scoped to.
466 + * @return array Settings limited to recognised keys.
467 + */
468 + private function filter_known_setting_keys(array $settings, string $category, string $context_type): array {
469 + $known = [];
470 +
471 + // $this->setting_categories maps category => label; the key lists live
472 + // in the generic store.
473 + $known = array_merge($known, $this->settings_manager->get_category_keys($category));
474 +
475 + if ($this->has_seo_manager($category)) {
476 + $known = array_merge(
477 + $known,
478 + array_keys($this->get_seo_manager($category)->get_default_settings($context_type))
479 + );
480 + }
481 +
482 + /**
483 + * Filter the setting keys a category accepts.
484 + *
485 + * @since 2.0.1
486 + *
487 + * @param string[] $known Recognised setting keys.
488 + * @param string $category Settings category.
489 + * @param string $context_type Context the write is scoped to.
490 + */
491 + $known = apply_filters('thinkrank_known_setting_keys', $known, $category, $context_type);
492 +
493 + if (empty($known)) {
494 + return $settings;
495 + }
496 +
497 + return array_intersect_key($settings, array_flip($known));
498 + }
499 +
419 500 private function strip_masked_secrets(array $settings): array {
420 501 foreach ($settings as $key => $value) {
421 502 if (!in_array($key, self::SENSITIVE_SETTING_KEYS, true)) {
422 503 continue;
@@ -448,10 +529,10 @@
448 529 if (!isset($this->setting_categories[$category])) {
449 530 continue;
450 531 }
451 532
452 - // Get settings for each category using Settings Manager
453 - $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);
454 535 $global_settings[$category] = $category_settings;
455 536
456 537 // Get schema if requested
457 538 if ($include_schema && $this->has_seo_manager($category)) {
@@ -587,9 +668,9 @@
587 668 // Get updated settings
588 669 $updated_settings = [];
589 670 foreach (array_keys($settings) as $category) {
590 671 if (isset($this->setting_categories[$category])) {
591 - $updated_settings[$category] = $this->settings_manager->get_settings($category);
672 + $updated_settings[$category] = $this->read_category($category);
592 673 }
593 674 }
594 675
595 676 return new WP_REST_Response([
@@ -634,9 +715,9 @@
634 715 );
635 716 }
636 717
637 718 // Get category settings
638 - $category_settings = $this->settings_manager->get_settings($category);
719 + $category_settings = $this->read_category($category);
639 720
640 721 // Get schema if requested
641 722 $schema = [];
642 723 if ($include_schema && $this->has_seo_manager($category)) {
@@ -728,8 +809,24 @@
728 809
729 810 // Reads mask secrets; never persist a mask back over the real one.
730 811 $settings = $this->strip_masked_secrets($settings);
731 812
813 + // Drop keys the category does not define. This route persisted any
814 + // key it was handed — a probe key written through it is still
815 + // readable in the settings table afterwards — which bloats the
816 + // store and lets a client invent settings the plugin will never
817 + // read (#395). Mirrors the same guard on the schema and
818 + // social-media routes.
819 + $settings = $this->filter_known_setting_keys($settings, $category, $context_type);
820 +
821 + if (empty($settings)) {
822 + return new WP_Error(
823 + 'invalid_settings',
824 + "No recognized settings were provided for category: {$category}",
825 + ['status' => 400]
826 + );
827 + }
828 +
732 829 $validation_result = ['valid' => true];
733 830
734 831 // Validate settings if requested
735 832 if ($validate_before_update && $this->has_seo_manager($category)) {
@@ -837,11 +934,12 @@
837 934 }
838 935
839 936 // Clear analytics cache when GSC/GA settings change so fresh data is fetched
840 937 if ($category === 'seo_analytics') {
938 + foreach (Analytics_Manager::dashboard_cache_keys() as $cache_key) {
939 + delete_transient($cache_key);
940 + }
841 941 foreach (['7d', '30d', '90d'] as $range) {
842 - delete_transient("analytics_dashboard_v5_{$range}");
843 - delete_transient("seo_opportunities_{$range}");
844 942 delete_transient("seo_insights_{$range}");
845 943 }
846 944 delete_transient('indexing_status');
847 945 }
@@ -852,11 +950,16 @@
852 950 // Get updated settings. Read them back from whichever store actually
853 951 // owns the category: the generic store returns [] for the categories it
854 952 // does not know, which would report a successful save as zero settings
855 953 // 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);
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);
859 962
860 963 return new WP_REST_Response([
861 964 'success' => true,
862 965 'data' => [
@@ -1033,9 +1136,9 @@
1033 1136 if (!isset($this->setting_categories[$category])) {
1034 1137 continue;
1035 1138 }
1036 1139
1037 - $export_data[$category] = $this->settings_manager->get_settings($category);
1140 + $export_data[$category] = $this->read_category($category);
1038 1141 }
1039 1142
1040 1143 // Never let secrets (API keys, OAuth tokens) leave the site in an
1041 1144 // export file — strip them entirely.
@@ -1171,9 +1274,9 @@
1171 1274 }
1172 1275
1173 1276 try {
1174 1277 // Check if settings exist and handle overwrite
1175 - $existing_settings = $this->settings_manager->get_settings($category);
1278 + $existing_settings = $this->read_category($category);
1176 1279
1177 1280 if (!empty($existing_settings) && !$overwrite_existing) {
1178 1281 $import_results[$category] = [
1179 1282 'success' => false,
@@ -1246,9 +1349,9 @@
1246 1349 // Create backup data
1247 1350 $backup_data = [];
1248 1351 foreach ($categories as $category) {
1249 1352 if (isset($this->setting_categories[$category])) {
1250 - $backup_data[$category] = $this->settings_manager->get_settings($category);
1353 + $backup_data[$category] = $this->read_category($category);
1251 1354 }
1252 1355 }
1253 1356
1254 1357 // Create backup metadata
@@ -1549,15 +1652,49 @@
1549 1652 * @since 1.0.0
1550 1653 *
1551 1654 * @return bool Permission status
1552 1655 */
1553 - public function check_read_permissions(): bool {
1656 + public function check_read_permissions(WP_REST_Request $request): bool {
1554 1657 // 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');
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 + );
1557 1663 }
1558 1664
1559 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 + /**
1560 1697 * Check permissions for managing settings
1561 1698 *
1562 1699 * @since 1.0.0
1563 1700 *
@@ -1562,10 +1699,12 @@
1562 1699 * @since 1.0.0
1563 1700 *
1564 1701 * @return bool Permission status
1565 1702 */
1566 - public function check_manage_permissions(): bool {
1567 - 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 + );
1568 1707 }
1569 1708
1570 1709 /**
1571 1710 * Check permissions for administrator-only settings operations.
@@ -2136,9 +2275,27 @@
2136 2275 private function create_settings_snapshot(array $categories, string $label): string {
2137 2276 $backup_data = [];
2138 2277 foreach ($categories as $category) {
2139 2278 if (isset($this->setting_categories[$category])) {
2140 - $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 '';
2141 2298 }
2142 2299 }
2143 2300
2144 2301 $backup_metadata = [