68d236961a
- deploy/.env.example: placeholders only (no real URLs, proper comments) - frontend/Dockerfile: Node 20 stub server for compose build - deploy/tests/test-compose-health.sh: OPS-1 compose health smoke test - deploy/tests/test-woo-base-url.sh: GC-4/5/FUND-25 validation suite - backend/app/api/settings.py: PUT rejects woo_base_url (422), consumer keys (422), text() wrapping for SQLAlchemy 2.x, key primary key fix - backend/tests/contract/test_woo_base_url.py: 8 contract tests for GC-4/GC-5 Tests: 39/39 backend tests pass (8 new, 31 existing green) Deploy: 5/5 shell tests pass (Docker unavailable for compose-up path)
234 lines
8.2 KiB
Python
234 lines
8.2 KiB
Python
"""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)
|