mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 08:34:26 +00:00
* fix(hosting): consistent provider-block visibility + fix Twelve Data toggle bug (#3089) T-Invest was the only provider block always rendered regardless of its checkbox state; now it follows the same pattern as every other provider (shown when tinkoff_invest or moex_public is enabled, since T-Invest also serves as a brand-logo fallback for MOEX-priced securities). Also fixes a related functional bug: unchecking every securities provider tried to clear the legacy securities_provider setting by assigning nil, but rails-settings-cached treats nil as "delete override", which silently reverted the field to its own default ("twelve_data") — re-enabling Twelve Data right after the user disabled it. Assigning "" instead persists the cleared state. Twelve Data and Yahoo Finance settings blocks can still be shown purely because they're the selected FX/exchange-rate provider even when unchecked for securities pricing; added an info notice explaining that instead of leaving it unexplained. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(hosting): address review feedback on #3333 - Drop the FX-only notice for Twelve Data/Yahoo Finance per feedback — the block staying visible while unchecked (because it's still the FX provider) doesn't need extra UI explanation. - Fix Codex finding: T-Invest settings must stay visible/manageable whenever a token is already configured, not just when tinkoff_invest or moex_public is checked. Security::Provided#import_brand_logo calls the T-Invest provider unconditionally for every non-crypto security once a token exists, regardless of price provider — hiding the field in that case would leave an active credential impossible to see, rotate, or clear through the UI. Reworded the notice to reflect the real, provider-independent reason instead of the narrower "MOEX only" framing. - Fix CodeRabbit finding: setting_test.rb's default-fallback test now isolates against SECURITIES_PROVIDER(S) env vars, and the explicit-clear test captures and restores the pre-test values instead of hardcoding a restore target. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * No overexplaining in code --------- Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Juan José Mata <jjmata@jjmata.com>
304 lines
12 KiB
Ruby
304 lines
12 KiB
Ruby
# Dynamic settings the user can change within the app (helpful for self-hosting)
|
|
class Setting < RailsSettings::Base
|
|
class ValidationError < StandardError; end
|
|
|
|
cache_prefix { "v1" }
|
|
|
|
# Third-party API keys
|
|
field :twelve_data_api_key, type: :string, default: ENV["TWELVE_DATA_API_KEY"]
|
|
field :openai_access_token, type: :string, default: ENV["OPENAI_ACCESS_TOKEN"]
|
|
field :openai_uri_base, type: :string, default: ENV["OPENAI_URI_BASE"]
|
|
field :openai_model, type: :string, default: ENV["OPENAI_MODEL"]
|
|
field :openai_json_mode, type: :string, default: ENV["LLM_JSON_MODE"]
|
|
field :openai_request_timeout, type: :integer, default: ENV["OPENAI_REQUEST_TIMEOUT"]&.to_i
|
|
field :anthropic_access_token, type: :string, default: ENV["ANTHROPIC_ACCESS_TOKEN"].presence || ENV["ANTHROPIC_API_KEY"].presence
|
|
field :anthropic_model, type: :string, default: ENV["ANTHROPIC_MODEL"]
|
|
field :anthropic_base_url, type: :string, default: ENV["ANTHROPIC_BASE_URL"]
|
|
field :llm_provider, type: :string, default: ENV.fetch("LLM_PROVIDER", "openai")
|
|
|
|
# LLM token budget (applies to every outbound LLM call: chat, auto-categorize,
|
|
# merchant detection, enhance-merchants, PDF processing). Defaults track
|
|
# Ollama's historical 2048-token baseline so local small-context models work
|
|
# out of the box. ENV overrides Setting at read time in Provider::Openai.
|
|
field :llm_context_window, type: :integer, default: ENV["LLM_CONTEXT_WINDOW"]&.to_i
|
|
field :llm_max_response_tokens, type: :integer, default: ENV["LLM_MAX_RESPONSE_TOKENS"]&.to_i
|
|
field :llm_max_items_per_call, type: :integer, default: ENV["LLM_MAX_ITEMS_PER_CALL"]&.to_i
|
|
|
|
# How long the chat UI waits for an assistant response before treating it as
|
|
# undelivered. Self-hosted users running local models on slow hardware need
|
|
# this well above the 90s default — a local model that takes minutes to
|
|
# generate would otherwise always trip the watchdog. Read via
|
|
# `Chat.response_timeout`, which applies ENV > Setting > default precedence.
|
|
field :ai_response_timeout, type: :integer, default: ENV["AI_RESPONSE_TIMEOUT"]&.to_i
|
|
field :external_assistant_url, type: :string
|
|
field :external_assistant_token, type: :string
|
|
field :external_assistant_agent_id, type: :string
|
|
field :brand_fetch_client_id, type: :string, default: ENV["BRAND_FETCH_CLIENT_ID"]
|
|
field :brand_fetch_high_res_logos, type: :boolean, default: ENV.fetch("BRAND_FETCH_HIGH_RES_LOGOS", "false") == "true"
|
|
|
|
BRAND_FETCH_LOGO_SIZE_STANDARD = 40
|
|
BRAND_FETCH_LOGO_SIZE_HIGH_RES = 120
|
|
# Matches both legacy single-segment URLs (`/apple.com/icon/...`) and
|
|
# explicit type-routed URLs introduced 2026 (`/crypto/BTC/icon/...`,
|
|
# `/domain/apple.com/icon/...`). `[^?]+` reaches across the extra slash
|
|
# so transform_brand_fetch_url can rewrite the size params on both shapes.
|
|
BRAND_FETCH_URL_PATTERN = %r{(https://cdn\.brandfetch\.io/[^?]+/icon/fallback/lettermark/)w/\d+/h/\d+(\?c=.+)}
|
|
|
|
def self.brand_fetch_logo_size
|
|
brand_fetch_high_res_logos ? BRAND_FETCH_LOGO_SIZE_HIGH_RES : BRAND_FETCH_LOGO_SIZE_STANDARD
|
|
end
|
|
|
|
# Transforms a stored Brandfetch URL to use the current logo size setting
|
|
def self.transform_brand_fetch_url(url)
|
|
return url unless url.present? && url.match?(BRAND_FETCH_URL_PATTERN)
|
|
|
|
size = brand_fetch_logo_size
|
|
url.gsub(BRAND_FETCH_URL_PATTERN, "\\1w/#{size}/h/#{size}\\2")
|
|
end
|
|
|
|
# Provider selection
|
|
field :exchange_rate_provider, type: :string, default: ENV.fetch("EXCHANGE_RATE_PROVIDER", "twelve_data")
|
|
field :securities_provider, type: :string, default: ENV.fetch("SECURITIES_PROVIDER", "twelve_data")
|
|
|
|
# Multi-provider: comma-separated list of enabled securities providers
|
|
field :securities_providers, type: :string, default: ENV.fetch("SECURITIES_PROVIDERS", "")
|
|
|
|
# New provider API keys (encrypted at rest — see EncryptedSettingFields below)
|
|
field :tiingo_api_key, type: :string, default: ENV["TIINGO_API_KEY"]
|
|
field :eodhd_api_key, type: :string, default: ENV["EODHD_API_KEY"]
|
|
field :alpha_vantage_api_key, type: :string, default: ENV["ALPHA_VANTAGE_API_KEY"]
|
|
field :tinkoff_invest_api_key, type: :string, default: ENV["TINKOFF_INVEST_API_KEY"]
|
|
|
|
# Property valuation (AVM) provider API keys
|
|
field :rentcast_api_key, type: :string, default: ENV["RENTCAST_API_KEY"]
|
|
field :realie_api_key, type: :string, default: ENV["REALIE_API_KEY"]
|
|
|
|
# Transparent encryption for API key fields. The `field` macro defines the
|
|
# raw getter/setter on the class. By prepending this module we intercept
|
|
# reads (decrypt) and writes (encrypt) while `super` delegates to the
|
|
# original getter/setter generated by rails-settings-cached.
|
|
#
|
|
# Backward-compatible: if decryption fails (e.g. the value was stored before
|
|
# encryption was enabled) the raw value is returned as-is.
|
|
module EncryptedSettingFields
|
|
ENCRYPTED_FIELDS = %i[
|
|
twelve_data_api_key
|
|
tiingo_api_key
|
|
eodhd_api_key
|
|
alpha_vantage_api_key
|
|
tinkoff_invest_api_key
|
|
rentcast_api_key
|
|
realie_api_key
|
|
openai_access_token
|
|
anthropic_access_token
|
|
external_assistant_token
|
|
].freeze
|
|
|
|
ENCRYPTED_FIELDS.each do |field_name|
|
|
define_method(field_name) do
|
|
raw = super()
|
|
decrypt_setting(raw)
|
|
end
|
|
|
|
define_method(:"#{field_name}=") do |value|
|
|
super(encrypt_setting(value))
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def setting_encryptor
|
|
@setting_encryptor ||= begin
|
|
key = ActiveSupport::KeyGenerator.new(
|
|
Rails.application.secret_key_base
|
|
).generate_key("setting_encryption", 32)
|
|
ActiveSupport::MessageEncryptor.new(key)
|
|
end
|
|
end
|
|
|
|
def encrypt_setting(value)
|
|
return value if value.blank?
|
|
setting_encryptor.encrypt_and_sign(value)
|
|
end
|
|
|
|
def decrypt_setting(value)
|
|
return value if value.blank?
|
|
setting_encryptor.decrypt_and_verify(value)
|
|
rescue ActiveSupport::MessageVerifier::InvalidSignature,
|
|
ActiveSupport::MessageEncryptor::InvalidMessage
|
|
# Value was stored before encryption was enabled — return as-is.
|
|
# It will be re-encrypted on next write.
|
|
value
|
|
end
|
|
end
|
|
|
|
class << self
|
|
prepend EncryptedSettingFields
|
|
end
|
|
|
|
def self.enabled_securities_providers
|
|
plural = ENV["SECURITIES_PROVIDERS"].presence || securities_providers.presence
|
|
if plural.present?
|
|
plural.to_s.split(",").map(&:strip).reject(&:blank?)
|
|
else
|
|
# Backward compat: fall back to singular setting
|
|
[ ENV["SECURITIES_PROVIDER"].presence || securities_provider.presence ].compact
|
|
end
|
|
end
|
|
|
|
# Sync settings - check both provider env vars for default
|
|
# Only defaults to true if neither provider explicitly disables pending
|
|
SYNCS_INCLUDE_PENDING_DEFAULT = begin
|
|
simplefin = ENV.fetch("SIMPLEFIN_INCLUDE_PENDING", "1") == "1"
|
|
plaid = ENV.fetch("PLAID_INCLUDE_PENDING", "1") == "1"
|
|
simplefin && plaid
|
|
end
|
|
field :syncs_include_pending, type: :boolean, default: SYNCS_INCLUDE_PENDING_DEFAULT
|
|
field :auto_sync_enabled, type: :boolean, default: ENV.fetch("AUTO_SYNC_ENABLED", "1") == "1"
|
|
field :auto_sync_time, type: :string, default: ENV.fetch("AUTO_SYNC_TIME", "02:22")
|
|
field :auto_sync_timezone, type: :string, default: ENV.fetch("AUTO_SYNC_TIMEZONE", "UTC")
|
|
|
|
AUTO_SYNC_TIME_FORMAT = /\A([01]?\d|2[0-3]):([0-5]\d)\z/
|
|
|
|
def self.valid_auto_sync_time?(time_str)
|
|
return false if time_str.blank?
|
|
AUTO_SYNC_TIME_FORMAT.match?(time_str.to_s.strip)
|
|
end
|
|
|
|
def self.valid_auto_sync_timezone?(timezone_str)
|
|
return false if timezone_str.blank?
|
|
ActiveSupport::TimeZone[timezone_str].present?
|
|
end
|
|
|
|
# Dynamic fields are now stored as individual entries with "dynamic:" prefix
|
|
# This prevents race conditions and ensures each field is independently managed
|
|
|
|
# Onboarding and app settings
|
|
ONBOARDING_STATES = %w[open closed invite_only].freeze
|
|
DEFAULT_ONBOARDING_STATE = begin
|
|
env_value = ENV["ONBOARDING_STATE"].to_s.presence || "open"
|
|
ONBOARDING_STATES.include?(env_value) ? env_value : "open"
|
|
end
|
|
|
|
field :onboarding_state, type: :string, default: DEFAULT_ONBOARDING_STATE
|
|
field :require_invite_for_signup, type: :boolean, default: false
|
|
field :require_email_confirmation, type: :boolean, default: ENV.fetch("REQUIRE_EMAIL_CONFIRMATION", "true") == "true"
|
|
field :invite_only_default_family_id, type: :string, default: nil
|
|
|
|
def self.validate_onboarding_state!(state)
|
|
return if ONBOARDING_STATES.include?(state)
|
|
|
|
raise ValidationError, I18n.t("settings.hostings.update.invalid_onboarding_state")
|
|
end
|
|
|
|
class << self
|
|
alias_method :raw_onboarding_state, :onboarding_state
|
|
alias_method :raw_onboarding_state=, :onboarding_state=
|
|
alias_method :raw_openai_model, :openai_model
|
|
alias_method :raw_openai_model=, :openai_model=
|
|
|
|
def onboarding_state
|
|
value = raw_onboarding_state
|
|
return "invite_only" if value.blank? && require_invite_for_signup
|
|
|
|
value.presence || DEFAULT_ONBOARDING_STATE
|
|
end
|
|
|
|
def onboarding_state=(state)
|
|
validate_onboarding_state!(state)
|
|
self.require_invite_for_signup = state == "invite_only"
|
|
self.raw_onboarding_state = state
|
|
end
|
|
|
|
def openai_model=(value)
|
|
old_value = raw_openai_model
|
|
self.raw_openai_model = value
|
|
|
|
if old_value != value && old_value.present?
|
|
Rails.logger.info("OpenAI model changed from #{old_value} to #{value}, clearing AI cache for all families")
|
|
Family.find_each do |family|
|
|
ClearAiCacheJob.perform_later(family)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Support dynamic field access via bracket notation
|
|
# First checks if it's a declared field, then falls back to individual dynamic entries
|
|
def [](key)
|
|
key_str = key.to_s
|
|
|
|
# Check if it's a declared field first
|
|
if respond_to?(key_str)
|
|
public_send(key_str)
|
|
else
|
|
# Fall back to individual dynamic entry lookup
|
|
find_by(var: dynamic_key_name(key_str))&.value
|
|
end
|
|
end
|
|
|
|
def []=(key, value)
|
|
key_str = key.to_s
|
|
|
|
# If it's a declared field, use the setter
|
|
if respond_to?("#{key_str}=")
|
|
public_send("#{key_str}=", value)
|
|
else
|
|
# Store as individual dynamic entry
|
|
dynamic_key = dynamic_key_name(key_str)
|
|
if value.nil?
|
|
where(var: dynamic_key).destroy_all
|
|
clear_cache
|
|
else
|
|
# Use upsert for atomic insert/update to avoid race conditions
|
|
upsert({ var: dynamic_key, value: value.to_yaml }, unique_by: :var)
|
|
clear_cache
|
|
end
|
|
end
|
|
end
|
|
|
|
# Check if a dynamic field exists (useful to distinguish nil value vs missing key)
|
|
def key?(key)
|
|
key_str = key.to_s
|
|
return true if respond_to?(key_str)
|
|
|
|
# Check if dynamic entry exists
|
|
where(var: dynamic_key_name(key_str)).exists?
|
|
end
|
|
|
|
# Delete a dynamic field
|
|
def delete(key)
|
|
key_str = key.to_s
|
|
return nil if respond_to?(key_str) # Can't delete declared fields
|
|
|
|
dynamic_key = dynamic_key_name(key_str)
|
|
value = self[key_str]
|
|
where(var: dynamic_key).destroy_all
|
|
clear_cache
|
|
value
|
|
end
|
|
|
|
# List all dynamic field keys (excludes declared fields)
|
|
def dynamic_keys
|
|
where("var LIKE ?", "dynamic:%").pluck(:var).map { |var| var.sub(/^dynamic:/, "") }
|
|
end
|
|
|
|
private
|
|
|
|
def dynamic_key_name(key_str)
|
|
"dynamic:#{key_str}"
|
|
end
|
|
end
|
|
|
|
# Validates OpenAI configuration requires model when custom URI base is set
|
|
def self.validate_openai_config!(uri_base: nil, model: nil)
|
|
# Use provided values or current settings
|
|
uri_base_value = uri_base.nil? ? openai_uri_base : uri_base
|
|
model_value = model.nil? ? openai_model : model
|
|
|
|
# If custom URI base is set, model must also be set
|
|
if uri_base_value.present? && model_value.blank?
|
|
raise ValidationError, "OpenAI model is required when custom URI base is configured"
|
|
end
|
|
end
|
|
end
|