get_component('settings'); if ($component instanceof self) { self::$instance = $component; return self::$instance; } } // Fallback: create standalone instance (during activation, etc.) self::$instance = new self(); return self::$instance; } /** * Settings cache * * @var array */ private array $cache = []; /** * Default settings * * @var array */ private array $defaults = [ // AI Settings 'ai_provider' => self::AI_PROVIDER_NONE, 'openai_api_key' => '', 'openai_model' => self::DEFAULT_OPENAI_MODEL, 'claude_api_key' => '', 'claude_model' => self::DEFAULT_CLAUDE_MODEL, // Recommended default (best speed/quality balance) 'gemini_api_key' => '', 'gemini_model' => self::DEFAULT_GEMINI_MODEL, 'openrouter_api_key' => '', 'openrouter_model' => self::DEFAULT_OPENROUTER_MODEL, 'max_tokens' => 1000, 'temperature' => 0.7, // Google API Keys (encrypted) 'google_analytics_api_key' => '', 'google_search_console_api_key' => '', 'google_pagespeed_api_key' => '', // Google OAuth Tokens (encrypted) 'google_access_token' => '', 'google_refresh_token' => '', 'google_token_expires_in' => 0, 'google_token_created' => 0, 'google_account_connected' => false, // Google Analytics Pro Settings. Read/written by thinkrank-pro's // GoogleAnalyticsSettings.js through the settings-management endpoint — // no consumer exists in THIS repo, so don't dead-key these. 'ga_analytics_account_id' => '', 'ga_analytics_data_stream_id' => '', // Performance Settings 'cache_duration' => 3600, 'max_requests_per_minute' => 10, 'enable_logging' => true, // Integration Settings 'api_timeout' => 30, 'enable_rate_limiting' => true, 'auto_test_connections' => true, 'retry_failed_requests' => true, // Social Platform Settings // Public IDs (not encrypted) 'facebook_app_id' => '', 'facebook_admins' => '', 'youtube_channel_id' => '', 'whatsapp_business_id' => '', // Verification codes (encrypted) 'pinterest_site_verification' => '', 'instagram_verification' => '', 'tiktok_verification' => '', // SEO Settings 'auto_optimize' => false, 'seo_score_threshold' => 70, 'enable_meta_generation' => true, 'enable_schema_markup' => true, // Author Archives Settings 'author_archives_enabled' => true, 'author_archives_index' => true, 'author_archives_show_empty' => false, 'author_archives_title' => self::DEFAULT_AUTHOR_ARCHIVES_TITLE, 'author_archives_meta_desc' => self::DEFAULT_AUTHOR_ARCHIVES_META_DESC, // UI Settings 'show_welcome_message' => true, 'dashboard_widgets' => ['seo_score', 'ai_usage', 'recent_briefs'], 'editor_panel_position' => 'side', // Advanced Settings 'debug_mode' => false, 'retry_attempts' => 3, // Exposes the standalone Migration admin page for re-running SEO data // imports after setup. Hidden by default; opt-in for advanced/support use. 'enable_migration_tools' => false, // Exposes ThinkRank's own export / restore. Off by default, like the // migration toggle above: both are occasional, admin-only tools, and a // menu item nobody asked for is a menu item in the way. Your data is // never locked in — the switch is one click away in // Settings > Import / Export, and turning it on immediately restores // the screen and the menu item. Off hides the export card and, unless // migration tools are on, the Import / Export menu item with it. 'enable_import_export' => false, // Privacy Settings 'data_retention_days' => 90, 'anonymize_logs' => true, 'share_usage_data' => false, // Integration Settings 'google_analytics_id' => '', 'search_console_property' => '', // SEO Analytics Settings 'seo_analytics_enabled' => false, 'seo_analytics_setup_completed' => false, 'seo_analytics_google_analytics_property_id' => '', 'seo_analytics_enable_ai_insights' => true, 'seo_analytics_enable_automated_alerts' => false, 'seo_analytics_enable_predictive_analysis' => false, 'seo_analytics_monitoring_frequency' => 3600, 'seo_analytics_alert_thresholds' => [], 'seo_analytics_report_schedule' => 'weekly', 'seo_analytics_data_retention_days' => 90, 'seo_analytics_cache_analytics_data' => true, // Uninstall Settings 'keep_data_on_uninstall' => true, // MCP (Model Context Protocol) Settings 'enable_mcp' => false, ]; /** * Encrypted settings keys * * @var array */ private array $encrypted_keys = [ // AI API Keys 'openai_api_key', 'claude_api_key', 'gemini_api_key', 'openrouter_api_key', // Google API Keys 'google_analytics_api_key', 'google_search_console_api_key', 'google_pagespeed_api_key', 'google_access_token', 'google_refresh_token', // Social Platform Verification Codes (sensitive) 'pinterest_site_verification', 'instagram_verification', 'tiktok_verification', ]; /** * Initialize settings * * @return void */ public function init(): void { add_action('admin_init', [$this, 'register_settings']); // Admin-only: a front-end pageview can never need this migration, and // the marker is a non-autoloaded option, so hooking it unconditionally // bought one dedicated query on every request for the life of the // install (#588). if (is_admin()) { add_action('init', [self::class, 'retire_seeded_ai_provider']); } } /** * Clear the OpenAI selection that older versions seeded on activation. * * Changing the default only helps installs created after the change. Every * site activated before it still carries `ai_provider = 'openai'` written by * Activator::set_default_options(), and still opens Settings warning about a * missing key for a provider nobody picked — the whole complaint in #572. * * "OpenAI with no OpenAI key" is provably not a user's choice: the settings * form refuses to save a provider without a key, so the only way to reach * that state is the old activation seed. A site that genuinely chose OpenAI * has a key and is left alone, as is any site on another provider. * * Version-gated so it runs once and never fights a user who later clears * their key but keeps the provider selected. * * @since 2.1.3 * * @return void */ public static function retire_seeded_ai_provider(): void { if (get_option(self::PROVIDER_MIGRATION_OPTION) === self::PROVIDER_MIGRATION_VERSION) { self::promote_migration_marker_to_autoload(); return; } // Record first: a site that somehow fails the checks below must not // re-test on every request for the rest of its life. Autoloaded on // purpose — it is a short write-once flag that is read on every admin // request, which is exactly what autoload is for; storing it // non-autoloaded bought a dedicated query per request instead (#588). update_option(self::PROVIDER_MIGRATION_OPTION, self::PROVIDER_MIGRATION_VERSION, true); if (get_option('thinkrank_ai_provider', null) !== 'openai') { return; } // Read the stored option directly rather than through Settings::get(). // Only emptiness matters here, and get() adds two things this decision // must not depend on: a per-instance cache that may already be primed, // and decryption that can yield '' for a key that is genuinely present. // The raw option is non-empty whenever a key exists, encrypted or not. if ('' !== (string) get_option('thinkrank_openai_api_key', '')) { // A real choice, backed by a key. Leave it. return; } // Write through the shared instance, not a throwaway one: set() refreshes // only the cache of the object it is called on, so a private instance // would leave the registered component serving the old value for the // rest of this request — including to the admin page it localizes. self::instance()->set('ai_provider', self::AI_PROVIDER_NONE); } /** * Move a legacy migration marker into the autoloaded set. * * Sites that ran the migration on 2.1.3 wrote the marker with * `autoload = false`, and the version gate above returns before the write * that would correct it — so those installs keep paying a dedicated query * to read a one-byte flag on every admin request, which is the cost #588 * was about. Promote it once. * * Free to test: alloptions is loaded from the object cache once per request * regardless, and an autoloaded marker is in it, so the steady state after * the promotion is a cache lookup and nothing else. wp_set_option_autoload() * arrived in WP 6.4 and the plugin supports 6.0, hence the guard. * * @since 2.2.0 * * @return void */ private static function promote_migration_marker_to_autoload(): void { if (!function_exists('wp_set_option_autoload') || !function_exists('wp_load_alloptions')) { return; } if (array_key_exists(self::PROVIDER_MIGRATION_OPTION, wp_load_alloptions())) { return; } wp_set_option_autoload(self::PROVIDER_MIGRATION_OPTION, true); } /** * Register WordPress settings * * @return void */ public function register_settings(): void { register_setting('thinkrank_settings', 'thinkrank_settings', [ 'sanitize_callback' => [$this, 'sanitize_settings'], 'default' => $this->defaults, ]); } /** * Warm the option cache for a batch of setting keys in one query. * * Every `thinkrank_*` option is autoload=off, so WordPress cannot serve * them from `alloptions` and each get() below is its own round-trip. A * caller that reads a known list of keys should prime it first: on a * ~1ms managed-hosting round-trip, sixteen of those on an anonymous * pageview is ~16ms spent on values the page may not use (#393). * * get() memoizes within the request, so only the first read of each key * ever reaches the database — this is what collapses those first reads. * Keys already memoized are left out of the batch. * * wp_prime_option_caches() is WP 6.4+; the plugin supports 6.0, so an * older site simply keeps the previous behaviour. * * @since 2.1.0 * * @param string[] $keys Setting keys, without the `thinkrank_` prefix. * @return void */ public function prime(array $keys): void { if (!function_exists('wp_prime_option_caches')) { return; } $unread = []; foreach ($keys as $key) { if (!isset($this->cache[$key])) { $unread[] = 'thinkrank_' . $key; } } if (!empty($unread)) { wp_prime_option_caches($unread); } } /** * Get setting value * * @param string $key Setting key * @param mixed $fallback Default value * @param int $user_id User ID (0 for global, >0 for user-specific) * @return mixed Setting value */ public function get(string $key, $fallback = null, int $user_id = 0) { // Check cache first $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key; if (isset($this->cache[$cache_key])) { return $this->maybe_decrypt($key, $this->cache[$cache_key]); } // Load from WordPress options or user meta if ($user_id > 0) { $value = get_user_meta($user_id, "thinkrank_{$key}", true); } else { $value = get_option("thinkrank_{$key}", null); } // Handle boolean settings with special logic for WordPress storage quirks if (isset($this->defaults[$key]) && is_bool($this->defaults[$key])) { // Convert string representations back to booleans if ('1' === $value || 1 === $value || true === $value || 'true' === $value) { $value = true; } elseif ('0' === $value || 0 === $value || false === $value || 'false' === $value) { $value = false; } elseif (null === $value || '' === $value) { // For boolean settings, empty string could mean false was stored // null means option doesn't exist, empty string means it was stored as empty if (null !== $value && $value === '') { // Empty string exists in DB, this likely means false was stored $value = false; } else { // Option doesn't exist, use default $value = $fallback ?? $this->defaults[$key]; } } } else { // Non-boolean settings: use default if not found. // // get_option() above is called with a null default, so null means // "no row" while '' means a value was deliberately stored. For most // keys we collapse the two — an empty string is treated as unset so // the documented default applies. Keys in EMPTY_IS_A_VALUE opt out: // there, clearing the field is a real choice the consumer honours, // and folding it back into the default made the save look like it // silently failed (#316). $empty_is_unset = !in_array($key, self::EMPTY_IS_A_VALUE, true); if (null === $value || ($empty_is_unset && '' === $value)) { $value = $fallback ?? ($this->defaults[$key] ?? null); } } // Cache the value $this->cache[$cache_key] = $value; return $this->maybe_decrypt($key, $value); } /** * Set setting value * * @param string $key Setting key * @param mixed $value Setting value * @param int $user_id User ID (0 for global, >0 for user-specific) * @return bool Success status */ public function set(string $key, $value, int $user_id = 0): bool { // Check if key exists in defaults if (!array_key_exists($key, $this->defaults)) { return false; } // Apply the declared per-key sanitizer before anything is stored. This // is the path essentially every caller takes, so skipping it left the // whole sanitize_setting() switch unreachable — max_tokens, // cache_duration and temperature persisted whatever string arrived. $value = $this->sanitize_setting($key, $value); // Encrypt if needed $encrypted_value = $this->maybe_encrypt($key, $value); // Save to WordPress options or user meta if ($user_id > 0) { $result = update_user_meta($user_id, "thinkrank_{$key}", $encrypted_value); } else { $option_name = "thinkrank_{$key}"; $is_sensitive = in_array($key, $this->encrypted_keys, true); // Determine if option exists $existing = get_option($option_name, '__tr_not_set__'); if ($existing === '__tr_not_set__') { // First-time save: set autoload=no for sensitive options $autoload = $is_sensitive ? 'no' : 'yes'; $result = add_option($option_name, $encrypted_value, '', $autoload); } else { // Update existing option; enforce autoload=no for sensitive options if ($is_sensitive) { $result = update_option($option_name, $encrypted_value, 'no'); } else { $result = update_option($option_name, $encrypted_value); } } // Verify value actually saved (update_option returns false when unchanged) $saved_value = get_option($option_name, 'NOT_FOUND'); // The loose branch is load-bearing: get_option() returns the stored // STRING ('1', '0', '30') while $encrypted_value may be the original // bool/int. On an unchanged re-save update_option() returns false, so // this comparison is the only thing that marks the save successful — // strict-only here made every unchanged-boolean re-save report failure. $values_match = ($saved_value === $encrypted_value) || ($saved_value == $encrypted_value && $encrypted_value !== 'NOT_FOUND'); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- intentional type-tolerant verify, see above. // Consider it successful if the value was saved correctly $result = $result || $values_match; } // Update cache if ($result) { $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key; $this->cache[$cache_key] = $encrypted_value; // Invalidate bulk cache when individual setting changes $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global'; wp_cache_delete($bulk_cache_key, 'thinkrank_settings'); } return $result !== false; } /** * Delete setting * * @param string $key Setting key * @param int $user_id User ID (0 for global, >0 for user-specific) * @return bool Success status */ public function delete(string $key, int $user_id = 0): bool { // Remove from WordPress options or user meta if ($user_id > 0) { $result = delete_user_meta($user_id, "thinkrank_{$key}"); } else { $result = delete_option("thinkrank_{$key}"); } // Remove from cache if ($result) { $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key; unset($this->cache[$cache_key]); // Also invalidate the bulk cache written by get_all(), or it serves // stale values for up to 5 minutes after a delete. $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global'; wp_cache_delete($bulk_cache_key, 'thinkrank_settings'); } return $result; } /** * Setting keys whose values are encrypted at rest. * * Exposed so callers that must never emit a credential — the data exporter * in particular — can filter against the same list this class encrypts * with, instead of keeping a copy that silently drifts when a key is added. * * @since 2.2.0 * * @return string[] Setting keys. */ public function get_encrypted_keys(): array { return $this->encrypted_keys; } /** * Get all settings (optimized with bulk caching) * * @param int $user_id User ID (0 for global) * @return array All settings */ public function get_all(int $user_id = 0): array { // Check bulk cache first $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global'; $cached_settings = wp_cache_get($bulk_cache_key, 'thinkrank_settings'); if ($cached_settings !== false) { return $cached_settings; } $settings = []; foreach (array_keys($this->defaults) as $key) { $settings[$key] = $this->get($key, null, $user_id); } // Cache all settings for 5 minutes wp_cache_set($bulk_cache_key, $settings, 'thinkrank_settings', 300); return $settings; } /** * Reset settings to defaults * * @param int $user_id User ID (0 for global) * @return bool Success status */ public function reset(int $user_id = 0): bool { $success = true; foreach (array_keys($this->defaults) as $key) { if (!$this->delete($key, $user_id)) { $success = false; } } // Clear cache — both the instance array and the bulk object cache // written by get_all(), which would otherwise return stale pre-reset // values for up to 5 minutes. $this->cache = []; $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global'; wp_cache_delete($bulk_cache_key, 'thinkrank_settings'); return $success; } /** * Sanitize settings * * @param array $settings Settings array * @return array Sanitized settings */ public function sanitize_settings(array $settings): array { $sanitized = []; foreach ($settings as $key => $value) { $sanitized[$key] = $this->sanitize_setting($key, $value); } return $sanitized; } /** * Sanitize a nested array, preserving its shape. * * Scalars keep their type (an int threshold stays an int); strings are * text-sanitized; objects are dropped, since no setting stores one. * * @since 2.2.0 * @param array $value Array to sanitize. * @param int $depth Current recursion depth. * @return array */ private function sanitize_array_recursive(array $value, int $depth = 0): array { // Settings are configuration, not arbitrary payloads; a cap keeps a // malformed deep structure from recursing without bound. if ($depth > 10) { return []; } $sanitized = []; foreach ($value as $item_key => $item) { $key = is_string($item_key) ? sanitize_key($item_key) : $item_key; if (is_array($item)) { $sanitized[$key] = $this->sanitize_array_recursive($item, $depth + 1); } elseif (is_object($item)) { continue; } elseif (is_bool($item) || is_int($item) || is_float($item)) { $sanitized[$key] = $item; } else { $sanitized[$key] = sanitize_text_field((string) $item); } } return $sanitized; } /** * Sanitize individual setting * * @param string $key Setting key * @param mixed $value Setting value * @return mixed Sanitized value */ private function sanitize_setting(string $key, $value) { // Only keys that are declared array-typed may receive an array. For a // scalar key an array/object value is malformed input, and the scalar // sanitizers below would fatal on it, so fall back to the declared // default instead of letting the bad value through. if (is_array($value) || is_object($value)) { if (!is_array($this->defaults[$key] ?? null)) { return $this->defaults[$key] ?? ''; } $value = (array) $value; } switch ($key) { case 'openai_api_key': case 'claude_api_key': case 'gemini_api_key': case 'openrouter_api_key': return sanitize_text_field($value); case 'ai_provider': // sanitize_key() maps '' to '', which is AI_PROVIDER_NONE — the // deliberate "no provider chosen" state, so it must survive here // rather than being folded back into the default (#572). $provider = sanitize_key($value); return in_array($provider, self::selectable_ai_providers(), true) ? $provider : $this->defaults['ai_provider']; case 'openai_model': case 'claude_model': case 'gemini_model': case 'openrouter_model': // Model ids may contain dots and slashes (e.g. "gpt-4.1" or // "openai/gpt-4o-mini") and users can enter custom models, so // sanitize_key() would corrupt them — use text-field sanitizing. return sanitize_text_field($value); case 'max_tokens': case 'cache_duration': case 'max_requests_per_minute': case 'seo_score_threshold': case 'api_timeout': case 'retry_attempts': case 'data_retention_days': case 'monitoring_frequency': return absint($value); case 'temperature': return (float) $value; case 'dashboard_widgets': return is_array($value) ? array_map('sanitize_key', $value) : []; case 'seo_analytics_alert_thresholds': // A threshold_name => value map where the values are numbers // (e.g. traffic_drop_percentage => 20) as well as strings, so // sanitize each value by its own type rather than forcing every // one through sanitize_key() — that turned ints into strings. if (!is_array($value)) { return []; } $thresholds = []; foreach ($value as $threshold_key => $threshold_value) { if (is_array($threshold_value)) { continue; } if (is_bool($threshold_value)) { // Keep booleans as booleans. is_numeric() is false for // one, so it used to fall to the string arm and a true // came back as "1" — invisible while this ran only on // the register_setting() path, now that set() routes // every write through here it is a type change on save. $thresholds[sanitize_key($threshold_key)] = $threshold_value; continue; } $thresholds[sanitize_key($threshold_key)] = is_numeric($threshold_value) ? $threshold_value + 0 : sanitize_text_field((string) $threshold_value); } return $thresholds; case 'google_analytics_property_id': case 'seo_analytics_google_analytics_property_id': case 'seo_analytics_report_schedule': return sanitize_text_field($value); case 'robots_txt_content': // Multi-line content — sanitize_text_field() collapses newlines // and would flatten the whole file onto a single line. set() // routes every write through here, so a caller that chose // sanitize_textarea_field() itself is otherwise silently // overridden by the default: arm below (#587). return sanitize_textarea_field($value); default: if (is_bool($value)) { return (bool) $value; } elseif (is_string($value)) { return sanitize_text_field($value); } elseif (is_array($value)) { // Recurse rather than drop. Skipping nested members was // harmless while this ran only on the register_setting() // path, but set() now routes every write through here, and // structured settings (lists of maps) were being silently // emptied on save. return $this->sanitize_array_recursive($value); } return $value; } } /** * Marker prefix for values encrypted with the libsodium scheme below. */ private const ENC_PREFIX = 'trenc:v1:'; /** * Encrypt secret settings (API keys, OAuth tokens) at rest using libsodium * authenticated encryption. Non-secret keys and environments without sodium * fall back to plaintext so behaviour stays stable. * * @param string $key Setting key * @param mixed $value Setting value * @return mixed Encrypted payload (string) or original value */ private function maybe_encrypt(string $key, $value) { if (!in_array($key, $this->encrypted_keys, true) || !is_string($value) || '' === $value) { return $value; } // Same scheme, same key, same prefix — it just lives in Secret_At_Rest // now so the MCP pairing token can use it too (#396). return Secret_At_Rest::encrypt($value); } /** * Decrypt a value previously encrypted by maybe_encrypt. Values without the * marker prefix are legacy plaintext and returned untouched (backward compat). * * @param string $key Setting key * @param mixed $value Setting value * @return mixed Decrypted or original value */ private function maybe_decrypt(string $key, $value) { if (!is_string($value) || strncmp($value, self::ENC_PREFIX, strlen(self::ENC_PREFIX)) !== 0) { return $value; // legacy plaintext or non-string } $plain = Secret_At_Rest::decrypt($value); if ('' === $plain) { // We reach here only when sodium is available and a key was // derivable, so this is a genuine failure: the auth salt changed // (config rotated, or the site was migrated without wp-config) or // the row is corrupt. The value is unrecoverable either way. // // Returning the ciphertext would send it upstream as a bearer // token or API key, producing an opaque 401 far from the cause. // Return empty so callers see "no credential" and can prompt for // a reconnect instead. return ''; } return $plain; } }