| @@ -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 | } |