PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.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/seo/class-seo-settings-manager.php +69 -98 1.0.0 → 2.9.0 View file →
@@ -14,8 +14,13 @@
14 14 declare(strict_types=1);
15 15
16 16 namespace ThinkRank\SEO;
17 17
18 +// Prevent direct access
19 +if (!defined('ABSPATH')) {
20 + exit;
21 +}
22 +
18 23 /**
19 24 * SEO Settings Manager Class
20 25 *
21 26 * Provides universal settings management for all SEO functionality.
@@ -174,16 +179,45 @@
174 179 * @param string $context_type The context type
175 180 * @param int|null $context_id Optional. Context ID
176 181 * @param array $settings Settings array to save
177 182 * @param string $category Optional. Settings category
178 - * @return bool True on success, false on failure
183 + * @return bool|null True on success, false on failure, null when this store
184 + * does not own the category (nothing was attempted).
179 185 */
180 - public function save_settings_by_category(string $context_type, ?int $context_id, array $settings, string $category = 'general'): bool {
181 - // Validate category
186 + /**
187 + * This store's boolean keys, so a read hands them back as booleans and its
188 + * own validator accepts them.
189 + *
190 + * validation_rules['boolean_fields'] is the source: without this, four of
191 + * the five ('auto_generate', 'validation_enabled', 'output_enabled',
192 + * 'cache_enabled') came back from the database as the string '1', and
193 + * save_settings_by_category() — which merges the existing settings before
194 + * saving — then failed its own validation. The endpoint reported HTTP 500
195 + * over a write the dedicated manager had already committed (#395).
196 + *
197 + * @since 2.0.1
198 + *
199 + * @return string[] Keys to coerce to boolean on read.
200 + */
201 + protected function boolean_setting_keys(): array {
202 + return array_values(array_unique(array_merge(
203 + parent::boolean_setting_keys(),
204 + $this->validation_rules['boolean_fields']
205 + )));
206 + }
207 +
208 + public function save_settings_by_category(string $context_type, ?int $context_id, array $settings, string $category = 'general'): ?bool {
209 + // Not this store's category. Callers address categories by the
210 + // endpoint's vocabulary (`social_media`, `site_identity`, …) while this
211 + // store registers its own (`social`, `general`, …), so an unrecognised
212 + // name is routine and must NOT be reported as a failed write — the
213 + // caller's dedicated manager is the store of record for those (#371).
214 + // Returning false here made every such save answer 500 while the
215 + // manager's row had already committed.
182 216 if (!isset($this->settings_categories[$category])) {
183 - return false;
217 + return null;
184 218 }
185 -
219 +
186 220 // Check if context is supported for this category
187 221 if (!in_array($context_type, $this->settings_categories[$category]['contexts'], true)) {
188 222 return false;
189 223 }
@@ -244,10 +278,13 @@
244 278 // Extract category if provided
245 279 $category = $settings_data['category'] ?? 'general';
246 280 unset($settings_data['category']);
247 281
248 - $success = $this->save_settings_by_category($context_type, $context_id, $settings_data, $category);
249 -
282 + // Normalise the tri-state to a boolean: an unknown category (null)
283 + // persisted nothing here, and this bulk API has no dedicated-manager
284 + // fallback, so it is a failure from this caller's point of view.
285 + $success = true === $this->save_settings_by_category($context_type, $context_id, $settings_data, $category);
286 +
250 287 $results[$context_key] = [
251 288 'success' => $success,
252 289 'context_type' => $context_type,
253 290 'context_id' => $context_id,
@@ -278,22 +315,23 @@
278 315 'validation_score' => 0
279 316 ];
280 317
281 318 // Get all settings
282 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- SEO settings statistics require direct database access, table name is validated
319 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO settings statistics require direct database access, table name is validated
283 320 if (!empty($context_type)) {
284 321 $sql = sprintf(
285 322 'SELECT context_type, context_id, setting_key, setting_value, updated_at FROM `%s` WHERE setting_category = %%s AND is_active = 1 AND context_type = %%s ORDER BY updated_at DESC',
286 323 $this->settings_table
287 324 );
325 + // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql from sprintf with validated table name.
288 326 $results = $this->wpdb->get_results(
289 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
327 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
290 328 $this->wpdb->prepare(
291 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
329 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
292 330 $sql,
293 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Parameters are validated and used as placeholders
331 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameters are validated and used as placeholders
294 332 $this->manager_type,
295 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $context_type is validated and used as parameter
333 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $context_type is validated and used as parameter
296 334 $context_type
297 335 ),
298 336 ARRAY_A
299 337 );
@@ -301,14 +339,15 @@
301 339 $sql = sprintf(
302 340 'SELECT context_type, context_id, setting_key, setting_value, updated_at FROM `%s` WHERE setting_category = %%s AND is_active = 1 ORDER BY updated_at DESC',
303 341 $this->settings_table
304 342 );
343 + // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql from sprintf with validated table name.
305 344 $results = $this->wpdb->get_results(
306 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
345 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
307 346 $this->wpdb->prepare(
308 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
347 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
309 348 $sql,
310 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Parameter is validated and used as placeholder
349 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameter is validated and used as placeholder
311 350 $this->manager_type
312 351 ),
313 352 ARRAY_A
314 353 );
@@ -673,19 +712,20 @@
673 712 $settings = $this->get_settings($context_type, $context_id);
674 713 $export_data['settings'][$context_type . ':' . ($context_id ?? 'site')] = $settings;
675 714 } else {
676 715 // Export all settings for this manager type
677 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- SEO settings export requires direct database access, table name is validated
716 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO settings export requires direct database access, table name is validated
678 717 $sql = sprintf(
679 718 'SELECT context_type, context_id, setting_key, setting_value FROM `%s` WHERE setting_category = %%s AND is_active = 1 ORDER BY context_type, context_id, setting_key',
680 719 $this->settings_table
681 720 );
721 + // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql from sprintf with validated table name.
682 722 $results = $this->wpdb->get_results(
683 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
723 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
684 724 $this->wpdb->prepare(
685 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
725 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
686 726 $sql,
687 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Parameter is validated and used as placeholder
727 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameter is validated and used as placeholder
688 728 $this->manager_type
689 729 ),
690 730 ARRAY_A
691 731 );
@@ -810,22 +850,23 @@
810 850 * @return int Overall validation score
811 851 */
812 852 private function calculate_overall_validation_score(string $context_type): int {
813 853 // Get all settings for context type
814 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- SEO validation score calculation requires direct database access, table name is validated
854 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO validation score calculation requires direct database access, table name is validated
815 855 if (!empty($context_type)) {
816 856 $sql = sprintf(
817 857 'SELECT context_type, context_id, setting_key, setting_value FROM `%s` WHERE setting_category = %%s AND is_active = 1 AND context_type = %%s',
818 858 $this->settings_table
819 859 );
860 + // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql from sprintf with validated table name.
820 861 $results = $this->wpdb->get_results(
821 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
862 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
822 863 $this->wpdb->prepare(
823 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
864 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
824 865 $sql,
825 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Parameters are validated and used as placeholders
866 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameters are validated and used as placeholders
826 867 $this->manager_type,
827 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $context_type is validated and used as parameter
868 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $context_type is validated and used as parameter
828 869 $context_type
829 870 ),
830 871 ARRAY_A
831 872 );
@@ -833,14 +874,15 @@
833 874 $sql = sprintf(
834 875 'SELECT context_type, context_id, setting_key, setting_value FROM `%s` WHERE setting_category = %%s AND is_active = 1',
835 876 $this->settings_table
836 877 );
878 + // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql from sprintf with validated table name.
837 879 $results = $this->wpdb->get_results(
838 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
880 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
839 881 $this->wpdb->prepare(
840 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
882 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
841 883 $sql,
842 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Parameter is validated and used as placeholder
884 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameter is validated and used as placeholder
843 885 $this->manager_type
844 886 ),
845 887 ARRAY_A
846 888 );
@@ -887,77 +929,6 @@
887 929 $default_settings['reset_at'] = current_time('mysql');
888 930 $default_settings['reset_by'] = get_current_user_id();
889 931
890 932 return $this->save_settings($context_type, $context_id, $default_settings);
891 - }
892 -
893 - /**
894 - * Get settings migration status
895 - *
896 - * @since 1.0.0
897 - *
898 - * @return array Migration status information
899 - */
900 - public function get_migration_status(): array {
901 - return [
902 - 'current_version' => '1.0.0',
903 - 'database_version' => get_option('thinkrank_seo_db_version', '0.0.0'),
904 - 'migration_needed' => version_compare(get_option('thinkrank_seo_db_version', '0.0.0'), '1.0.0', '<'),
905 - 'last_migration' => get_option('thinkrank_seo_last_migration', ''),
906 - 'migration_log' => get_option('thinkrank_seo_migration_log', [])
907 - ];
908 - }
909 -
910 - /**
911 - * Perform settings migration
912 - *
913 - * @since 1.0.0
914 - *
915 - * @param string $from_version Source version
916 - * @param string $to_version Target version
917 - * @return array Migration results
918 - */
919 - public function migrate_settings(string $from_version, string $to_version): array {
920 - $migration_results = [
921 - 'success' => false,
922 - 'migrated_count' => 0,
923 - 'errors' => [],
924 - 'from_version' => $from_version,
925 - 'to_version' => $to_version,
926 - 'started_at' => current_time('mysql')
927 - ];
928 -
929 - try {
930 - // Perform version-specific migrations
931 - switch ($from_version) {
932 - case '0.0.0':
933 - // Initial migration - set up default settings
934 - $contexts = ['site', 'post', 'page', 'product'];
935 - foreach ($contexts as $context) {
936 - $defaults = $this->get_default_settings($context);
937 - $this->save_settings($context, null, $defaults);
938 - $migration_results['migrated_count']++;
939 - }
940 - break;
941 - default:
942 - $migration_results['errors'][] = "No migration path defined for version {$from_version}";
943 - break;
944 - }
945 -
946 - if (empty($migration_results['errors'])) {
947 - $migration_results['success'] = true;
948 - update_option('thinkrank_seo_db_version', $to_version);
949 - update_option('thinkrank_seo_last_migration', current_time('mysql'));
950 -
951 - // Log migration
952 - $migration_log = get_option('thinkrank_seo_migration_log', []);
953 - $migration_log[] = $migration_results;
954 - update_option('thinkrank_seo_migration_log', array_slice($migration_log, -10)); // Keep last 10 migrations
955 - }
956 - } catch (\Exception $e) {
957 - $migration_results['errors'][] = 'Migration failed: ' . $e->getMessage();
958 - }
959 -
960 - $migration_results['completed_at'] = current_time('mysql');
961 - return $migration_results;
962 933 }
963 934 }