"""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 sqlalchemy import text 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 and woo_base_url are rejected (woo_base_url is compose-only). Consumer keys are included as fields so they can be explicitly rejected. """ woo_base_url: str | None = None woo_wp_username: str | None = None woo_app_password: str | None = None woo_consumer_key: str | None = None woo_consumer_secret: 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(text("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 and woo_base_url (compose-only, read-only in UI) 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.", ) if "woo_base_url" in payload: raise HTTPException( status_code=422, detail="woo_base_url is set via compose environment, not editable via settings.", ) # 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( text("SELECT key FROM settings WHERE key = :key"), {"key": field} ) existing = setting.fetchone() if existing: await session.execute( text("UPDATE settings SET value_enc = :val, updated_at = :now WHERE key = :key"), {"val": ct, "now": now, "key": field}, ) else: await session.execute( text("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)