"""Pydantic models for the dashboard widget system. Widgets are either: * **service-bound** — reference a ``service_id`` and a ``widget_kind`` declared by that service's definition (Grafana link, Prometheus metric, Jellyfin activity, SSH task output); or * **built-in** — ``service_id`` is null and ``widget_kind`` is one of the service-less kinds (backups, static). """ from __future__ import annotations from typing import Any from pydantic import BaseModel, Field, field_validator, model_validator FORBIDDEN_CONFIG_KEYS = { "password", "token", "secret", "api_key", "apikey", "private_key", "passphrase", "credential", } def _looks_secret(value: Any) -> bool: """Heuristic to detect values that look like secrets/tokens.""" if not isinstance(value, str) or not value.strip(): return False lowered = value.lower() if value.startswith("sk-") or value.startswith("eyJ"): return True if len(value) > 64 and lowered.isalnum(): return True return False def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]: """Recursively reject credential keys and secret-looking values.""" for key, value in config.items(): if key.lower() in FORBIDDEN_CONFIG_KEYS: raise ValueError(f"Credential key '{key}' is not allowed in widget config") if _looks_secret(value): raise ValueError(f"Value for '{key}' looks like a secret") if isinstance(value, dict): _validate_config_keys(value) elif isinstance(value, list): for item in value: if isinstance(item, dict): _validate_config_keys(item) return config class _WidgetInstanceBase(BaseModel): """Shared fields between input and output widget models.""" service_id: str | None = None widget_kind: str = Field(..., min_length=1) title: str = Field(..., min_length=1) config: dict[str, Any] = Field(default_factory=dict) enabled: bool = True sort_order: int = Field(default=0, ge=0) @field_validator("config") @classmethod def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]: return _validate_config_keys(value or {}) @model_validator(mode="after") def _validate_kind(self) -> "_WidgetInstanceBase": # The kind must be non-empty (Field enforces it); service_id may be None # for built-ins. Deeper validation happens in the router against the # service definition / built-in registry. return self class WidgetInstanceInput(_WidgetInstanceBase): """Payload for creating or updating a widget instance.""" id: str | None = None class WidgetInstance(_WidgetInstanceBase): """Persisted widget instance returned by the API.""" id: str created_at: int updated_at: int class BuiltinWidgetKindInfo(BaseModel): """Metadata about a built-in (service-less) widget kind.""" kind: str name: str description: str config_schema: dict[str, Any] default_config: dict[str, Any] refresh_interval_ms: int class WidgetDataResponse(BaseModel): """Response from the per-widget data endpoint.""" widget_id: str data: dict[str, Any] | None = None error: str | None = None fetched_at: int