diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..c285d41b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,21 @@ +* text=auto eol=lf + +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.zip binary +*.gz binary +*.tar binary +*.db binary +*.sqlite binary +*.p12 binary +*.pfx binary +*.crt binary +*.pem binary diff --git a/backend/src/core/__tests__/test_config_manager_compat.py b/backend/src/core/__tests__/test_config_manager_compat.py new file mode 100644 index 00000000..7d461c30 --- /dev/null +++ b/backend/src/core/__tests__/test_config_manager_compat.py @@ -0,0 +1,53 @@ +# [DEF:backend.src.core.__tests__.test_config_manager_compat:Module] +# @TIER: STANDARD +# @SEMANTICS: config-manager, compatibility, payload, tests +# @PURPOSE: Verifies ConfigManager compatibility wrappers preserve legacy payload sections. +# @LAYER: Domain +# @RELATION: VERIFIES -> ConfigManager + +from src.core.config_manager import ConfigManager +from src.core.config_models import AppConfig, GlobalSettings + + +# [DEF:test_get_payload_preserves_legacy_sections:Function] +# @PURPOSE: Ensure get_payload merges typed config into raw payload without dropping legacy sections. +def test_get_payload_preserves_legacy_sections(): + manager = ConfigManager.__new__(ConfigManager) + manager.raw_payload = {"notifications": {"smtp": {"host": "mail.local"}}} + manager.config = AppConfig(environments=[], settings=GlobalSettings()) + + payload = manager.get_payload() + + assert payload["settings"]["migration_sync_cron"] == "0 2 * * *" + assert payload["notifications"]["smtp"]["host"] == "mail.local" +# [/DEF:test_get_payload_preserves_legacy_sections:Function] + + +# [DEF:test_save_config_accepts_raw_payload_and_keeps_extras:Function] +# @PURPOSE: Ensure save_config accepts raw dict payload, refreshes typed config, and preserves extra sections. +def test_save_config_accepts_raw_payload_and_keeps_extras(monkeypatch): + manager = ConfigManager.__new__(ConfigManager) + manager.raw_payload = {} + manager.config = AppConfig(environments=[], settings=GlobalSettings()) + + persisted = {} + + def _capture_save(config, session=None): + persisted["payload"] = manager.get_payload() + + monkeypatch.setattr(manager, "_save_config_to_db", _capture_save) + + manager.save_config( + { + "environments": [], + "settings": GlobalSettings().model_dump(), + "notifications": {"telegram": {"bot_token": "secret"}}, + } + ) + + assert manager.raw_payload["notifications"]["telegram"]["bot_token"] == "secret" + assert manager.config.settings.migration_sync_cron == "0 2 * * *" + assert persisted["payload"]["notifications"]["telegram"]["bot_token"] == "secret" +# [/DEF:test_save_config_accepts_raw_payload_and_keeps_extras:Function] + +# [/DEF:backend.src.core.__tests__.test_config_manager_compat:Module] diff --git a/backend/src/core/config_manager.py b/backend/src/core/config_manager.py index 7965e5c1..7a26bc7b 100644 --- a/backend/src/core/config_manager.py +++ b/backend/src/core/config_manager.py @@ -15,7 +15,7 @@ import json import os from pathlib import Path -from typing import Optional, List +from typing import Any, Optional, List from sqlalchemy.orm import Session @@ -44,6 +44,7 @@ class ConfigManager: logger.reason(f"Initializing ConfigManager with legacy path: {config_path}") self.config_path = Path(config_path) + self.raw_payload: dict[str, Any] = {} self.config: AppConfig = self._load_config() configure_logger(self.config.settings.logging) @@ -69,6 +70,20 @@ class ConfigManager: ) # [/DEF:_default_config:Function] + # [DEF:_sync_raw_payload_from_config:Function] + # @PURPOSE: Merge typed AppConfig state into raw payload while preserving unsupported legacy sections. + # @PRE: self.config is initialized as AppConfig. + # @POST: self.raw_payload contains AppConfig fields refreshed from self.config. + # @SIDE_EFFECT: Mutates self.raw_payload in memory. + # @DATA_CONTRACT: Input(None) -> Output(dict[str, Any]) + def _sync_raw_payload_from_config(self) -> dict[str, Any]: + with belief_scope("ConfigManager._sync_raw_payload_from_config"): + payload = dict(self.raw_payload) if isinstance(self.raw_payload, dict) else {} + payload.update(self.config.model_dump()) + self.raw_payload = payload + return payload + # [/DEF:_sync_raw_payload_from_config:Function] + # [DEF:_load_from_legacy_file:Function] # @PURPOSE: Load legacy JSON configuration for migration fallback path. # @PRE: self.config_path is initialized. @@ -79,15 +94,18 @@ class ConfigManager: with belief_scope("_load_from_legacy_file"): if not self.config_path.exists(): logger.info("[_load_from_legacy_file][Action] Legacy config file not found, using defaults") + self.raw_payload = self._default_config().model_dump() return self._default_config() try: with open(self.config_path, "r", encoding="utf-8") as f: data = json.load(f) + self.raw_payload = dict(data) if isinstance(data, dict) else {} logger.info("[_load_from_legacy_file][Coherence:OK] Legacy configuration loaded") return AppConfig(**data) except Exception as e: logger.error(f"[_load_from_legacy_file][Coherence:Failed] Error loading legacy config: {e}") + self.raw_payload = self._default_config().model_dump() return self._default_config() # [/DEF:_load_from_legacy_file:Function] @@ -115,6 +133,7 @@ class ConfigManager: record = self._get_record(session) if record and record.payload: logger.reason("Configuration found in database") + self.raw_payload = dict(record.payload) config = AppConfig(**record.payload) logger.reflect("Database configuration validated") return config @@ -126,6 +145,7 @@ class ConfigManager: return config except Exception as e: logger.explore(f"Error loading config from DB: {e}") + self.raw_payload = self._default_config().model_dump() return self._default_config() finally: session.close() @@ -146,7 +166,7 @@ class ConfigManager: db = session or SessionLocal() try: record = self._get_record(db) - payload = config.model_dump() + payload = self._sync_raw_payload_from_config() if record is None: logger.reason("Creating new global configuration record") record = AppConfigRecord(id="global", payload=payload) @@ -187,6 +207,38 @@ class ConfigManager: return self.config # [/DEF:get_config:Function] + # [DEF:get_payload:Function] + # @PURPOSE: Return full persisted payload including sections outside typed AppConfig schema. + # @PRE: Manager state is initialized. + # @POST: Returns dict payload with current AppConfig fields synchronized. + # @SIDE_EFFECT: Refreshes self.raw_payload from self.config before returning. + # @DATA_CONTRACT: Input(None) -> Output(dict[str, Any]) + def get_payload(self) -> dict[str, Any]: + with belief_scope("ConfigManager.get_payload"): + return dict(self._sync_raw_payload_from_config()) + # [/DEF:get_payload:Function] + + # [DEF:save_config:Function] + # @PURPOSE: Persist configuration provided either as typed AppConfig or raw payload dict. + # @PRE: config is AppConfig or dict compatible with AppConfig core schema. + # @POST: self.config and self.raw_payload are synchronized and persisted to DB. + # @SIDE_EFFECT: Mutates in-memory config state, writes database record, may reconfigure logger. + # @DATA_CONTRACT: Input(AppConfig|dict[str, Any]) -> Output(None) + def save_config(self, config: AppConfig | dict[str, Any]): + with belief_scope("ConfigManager.save_config"): + if isinstance(config, AppConfig): + self.config = config + self.raw_payload = dict(config.model_dump()) + elif isinstance(config, dict): + self.raw_payload = dict(config) + self.config = AppConfig(**config) + else: + raise TypeError("config must be an AppConfig instance or dict payload") + + self._save_config_to_db(self.config) + configure_logger(self.config.settings.logging) + # [/DEF:save_config:Function] + # [DEF:update_global_settings:Function] # @PURPOSE: Replace global settings and persist the resulting configuration. # @PRE: settings is GlobalSettings. diff --git a/build.sh b/build.sh index ccc3edfa..3c3f37b6 100755 --- a/build.sh +++ b/build.sh @@ -5,6 +5,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" +BACKEND_ENV_FILE="$SCRIPT_DIR/backend/.env" + if ! command -v docker >/dev/null 2>&1; then echo "Error: docker is not installed or not in PATH." exit 1 @@ -19,6 +21,65 @@ else exit 1 fi +ensure_backend_encryption_key() { + if command -v python3 >/dev/null 2>&1; then + python3 - "$BACKEND_ENV_FILE" <<'PY' +import base64 +import os +import sys +from pathlib import Path + + +def is_valid_fernet_key(raw_value: str) -> bool: + value = raw_value.strip() + if not value: + return False + + try: + decoded = base64.urlsafe_b64decode(value.encode()) + except Exception: + return False + + return len(decoded) == 32 + + +def generate_fernet_key() -> str: + return base64.urlsafe_b64encode(os.urandom(32)).decode() + + +env_path = Path(sys.argv[1]) +env_path.parent.mkdir(parents=True, exist_ok=True) + +existing_lines: list[str] = [] +existing_key: str | None = None + +if env_path.exists(): + existing_lines = env_path.read_text(encoding="utf-8").splitlines() + for line in existing_lines: + if line.startswith("ENCRYPTION_KEY="): + candidate = line.partition("=")[2].strip() + if is_valid_fernet_key(candidate): + existing_key = candidate + break + +if existing_key is None: + generated_key = generate_fernet_key() + filtered_lines = [line for line in existing_lines if not line.startswith("ENCRYPTION_KEY=")] + filtered_lines.append(f"ENCRYPTION_KEY={generated_key}") + env_path.write_text("\n".join(filtered_lines) + "\n", encoding="utf-8") + print(f"[build] ENCRYPTION_KEY ensured in {env_path}") +else: + print(f"[build] Existing ENCRYPTION_KEY reused from {env_path}") +PY + return + fi + + echo "Error: python3 is required to generate backend/.env with ENCRYPTION_KEY." + exit 1 +} + +ensure_backend_encryption_key + echo "[1/2] Building project images..." "${COMPOSE_CMD[@]}" build diff --git a/docker-compose.yml b/docker-compose.yml index b7d14d2a..86a80733 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,8 @@ services: dockerfile: docker/backend.Dockerfile container_name: ss_tools_backend restart: unless-stopped + env_file: + - ./backend/.env depends_on: db: condition: service_healthy diff --git a/frontend/src/components/llm/ProviderConfig.svelte b/frontend/src/components/llm/ProviderConfig.svelte index 8fd2c0b6..c52579cf 100644 --- a/frontend/src/components/llm/ProviderConfig.svelte +++ b/frontend/src/components/llm/ProviderConfig.svelte @@ -31,6 +31,7 @@ let testStatus = $state({ type: "", message: "" }); let isTesting = $state(false); let togglingProviderIds = $state(new Set()); + let deletingProviderIds = $state(new Set()); function isMultimodalModel(modelName) { const token = (modelName || "").toLowerCase(); @@ -155,6 +156,38 @@ onSave(); } } + + async function handleDelete(provider) { + if (deletingProviderIds.has(provider.id)) return; + if ( + !confirm( + $t.llm.delete_confirm.replace("{name}", provider.name || provider.id), + ) + ) { + return; + } + + deletingProviderIds.add(provider.id); + deletingProviderIds = new Set(deletingProviderIds); + + try { + await requestApi(`/llm/providers/${provider.id}`, "DELETE"); + providers = providers.filter(({ id }) => id !== provider.id); + addToast($t.llm.delete_success.replace("{name}", provider.name), "success"); + onSave(); + } catch (err) { + addToast( + $t.llm.delete_failed.replace( + "{error}", + err?.message || $t.common.error, + ), + "error", + ); + } finally { + deletingProviderIds.delete(provider.id); + deletingProviderIds = new Set(deletingProviderIds); + } + }