"""Settings endpoint: GET returns masked settings, PUT stores encrypted secrets. Per OPENAPI.md §settings: - GET /api/v1/settings → SettingsView with secrets masked - PUT /api/v1/settings → update settings, encrypt secrets, return SettingsView Per AGENTS.md §4: - Woo credentials encrypted AES-GCM at rest - Ciphertext never appears in GET settings - Missing SLAB_AES_KEY refuses startup """ from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field from app.config import Settings from app.db.engine import get_session from app.db.models import Setting from app.security.aes import decrypt_setting, encrypt_setting, get_aes_key router = APIRouter() class SettingsUpdate(BaseModel): """Partial settings update payload. Woo consumer keys are rejected. """ woo_wp_username: str | None = None woo_app_password: str | None = None inference_base_url: str | None = None inference_api_key: str | None = None inference_model: str | None = None inference_enabled: bool | None = None content_llm_enabled: bool | None = None brand_voice: str | None = None geo_context: str | None = None woo_create_status: str | None = Field(default=None, description="'draft' | 'publish'") aspect_ratio: str | None = None output_px: int | None = None output_px_min: int | None = None fill_target: float | None = None class SettingsView(BaseModel): """Settings read model. Secrets masked.""" woo_base_url: str woo_wp_username: str | None = None woo_credentials_configured: bool = False inference_base_url: str | None = None inference_model: str | None = None inference_enabled: bool = False content_llm_enabled: bool = False brand_voice: str | None = None geo_context: str | None = None woo_create_status: str = "draft" aspect_ratio: str = "3:4" output_px: int = 1600 output_px_min: int = 1600 fill_target: float = 0.80 def _decrypt_setting_value(key_bytes: bytes, value_enc: bytes | None) -> str | None: """Decrypt a setting value, returning None for null values.""" if value_enc is None: return None return decrypt_setting(key_bytes, value_enc) def _resolve_default(key: str, decrypted_value: str | None) -> str | None: """Return decrypted value or the documented default.""" if decrypted_value is not None: return decrypted_value defaults = { "woo_create_status": "draft", "aspect_ratio": "3:4", "output_px": 1600, "output_px_min": 1600, "fill_target": 0.80, } return defaults.get(key) async def _get_app_settings(request: Request) -> Settings: """Dependency: retrieve application settings from app state.""" return request.app.state.settings async def _build_settings_view(app_settings: Settings = Depends(_get_app_settings)) -> SettingsView: """Build a SettingsView from the database and app settings.""" key_bytes = get_aes_key(app_settings.aes_key) session = await get_session() rows = await session.execute("SELECT key, value_enc FROM settings") all_rows = rows.all() await session.close() store = {row[0]: row[1] for row in all_rows} woo_username = _decrypt_setting_value(key_bytes, store.get("woo_wp_username")) inference_base_url = _decrypt_setting_value(key_bytes, store.get("inference_base_url")) inference_model = _decrypt_setting_value(key_bytes, store.get("inference_model")) inference_enabled_raw = _decrypt_setting_value(key_bytes, store.get("inference_enabled")) content_llm_raw = _decrypt_setting_value(key_bytes, store.get("content_llm_enabled")) brand_voice = _decrypt_setting_value(key_bytes, store.get("brand_voice")) geo_context = _decrypt_setting_value(key_bytes, store.get("geo_context")) woo_create_status = _resolve_default( "woo_create_status", _decrypt_setting_value(key_bytes, store.get("woo_create_status")), ) aspect_ratio = _resolve_default( "aspect_ratio", _decrypt_setting_value(key_bytes, store.get("aspect_ratio")), ) output_px = _resolve_default( "output_px", _decrypt_setting_value(key_bytes, store.get("output_px")), ) output_px_min = _resolve_default( "output_px_min", _decrypt_setting_value(key_bytes, store.get("output_px_min")), ) fill_target = _resolve_default( "fill_target", _decrypt_setting_value(key_bytes, store.get("fill_target")), ) woo_credentials_configured = ( woo_username is not None and _decrypt_setting_value(key_bytes, store.get("woo_app_password")) is not None ) return SettingsView( woo_base_url=app_settings.woo_base_url, woo_wp_username=woo_username, woo_credentials_configured=woo_credentials_configured, inference_base_url=inference_base_url, inference_model=inference_model, inference_enabled=( inference_enabled_raw == "true" if inference_enabled_raw is not None else False ), content_llm_enabled=( content_llm_raw == "true" if content_llm_raw is not None else False ), brand_voice=brand_voice, geo_context=geo_context, woo_create_status=woo_create_status, aspect_ratio=aspect_ratio, output_px=output_px, output_px_min=output_px_min, fill_target=fill_target, ) @router.get("/api/v1/settings", response_model=SettingsView) async def get_settings(app_settings: Settings = Depends(_get_app_settings)) -> SettingsView: """GET settings. Secrets (app password, inference key) are never returned. Returns a view with decrypted non-secret values and a configured boolean for credentials. """ return await _build_settings_view(app_settings) @router.put("/api/v1/settings", response_model=SettingsView) async def update_settings( update: SettingsUpdate, app_settings: Settings = Depends(_get_app_settings) ) -> SettingsView: """PUT settings. Encrypt secrets, store non-secrets. Return updated view. Rejects consumer key fields. Returns 422 for invalid woo_create_status. """ # Reject consumer keys payload = update.model_dump(exclude_unset=True) if "woo_consumer_key" in payload or "woo_consumer_secret" in payload: raise HTTPException( status_code=422, detail="WooCommerce consumer keys are not supported. Use WordPress application passwords.", ) # Validate woo_create_status if update.woo_create_status is not None and update.woo_create_status not in ( "draft", "publish", ): raise HTTPException( status_code=422, detail="woo_create_status must be 'draft' or 'publish'", ) key_bytes = get_aes_key(app_settings.aes_key) now = datetime.now(timezone.utc).isoformat() session = await get_session() for field, value in payload.items(): if value is None: continue ct = encrypt_setting(key_bytes, str(value)) setting = await session.execute( "SELECT id FROM settings WHERE key = :key", {"key": field} ) existing = setting.fetchone() if existing: await session.execute( "UPDATE settings SET value_enc = :val, updated_at = :now WHERE key = :key", {"val": ct, "now": now, "key": field}, ) else: await session.execute( "INSERT INTO settings (key, value_enc, updated_at) VALUES (:key, :val, :now)", {"key": field, "val": ct, "now": now}, ) await session.commit() # Return updated view return await _build_settings_view(app_settings)