media-rip/backend/app/services/settings.py
xpltd 1592407658 First-run admin setup wizard, password persistence, forced setup gate
- Admin enabled by default (was opt-in via env var)
- New /admin/status (public) and /admin/setup (first-run only) endpoints
- Setup endpoint locked after first use (returns 403)
- Admin password persisted to SQLite config table (survives restarts)
- Change password now persists to DB (was in-memory only)
- Frontend router guard forces /admin redirect until setup is complete
- AdminSetup.vue wizard: username + password + confirm
- Public config exposes admin_enabled/admin_setup_complete for frontend
- TLS warning only fires when password is actually configured
2026-03-21 20:01:13 -05:00

132 lines
4.4 KiB
Python

"""Persistent settings service — reads/writes the `config` table in SQLite.
Settings priority (highest wins):
1. Admin writes via UI → persisted in SQLite `config` table
2. Environment variables (MEDIARIP__*)
3. config.yaml
4. Hardcoded defaults
On startup, persisted settings are loaded and applied to the AppConfig.
Admin writes go to DB immediately and update the live config.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
import aiosqlite
logger = logging.getLogger("mediarip.settings")
# Keys that can be persisted via admin UI
ADMIN_WRITABLE_KEYS = {
"welcome_message",
"default_video_format",
"default_audio_format",
"privacy_mode",
"privacy_retention_hours",
"max_concurrent",
"session_mode",
"session_timeout_hours",
"admin_username",
"admin_password_hash",
"purge_enabled",
"purge_max_age_hours",
}
async def load_persisted_settings(db: aiosqlite.Connection) -> dict:
"""Load all persisted settings from the config table."""
cursor = await db.execute("SELECT key, value FROM config")
rows = await cursor.fetchall()
settings = {}
for row in rows:
key = row["key"]
raw = row["value"]
if key in ADMIN_WRITABLE_KEYS:
settings[key] = _deserialize(key, raw)
return settings
async def save_setting(db: aiosqlite.Connection, key: str, value: object) -> None:
"""Persist a single setting to the config table."""
if key not in ADMIN_WRITABLE_KEYS:
raise ValueError(f"Setting '{key}' is not admin-writable")
now = datetime.now(timezone.utc).isoformat()
serialized = json.dumps(value)
await db.execute(
"""
INSERT INTO config (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
""",
(key, serialized, now),
)
await db.commit()
logger.info("Persisted setting %s", key)
async def save_settings(db: aiosqlite.Connection, settings: dict) -> list[str]:
"""Persist multiple settings. Returns list of keys saved."""
saved = []
for key, value in settings.items():
if key in ADMIN_WRITABLE_KEYS:
await save_setting(db, key, value)
saved.append(key)
return saved
async def delete_setting(db: aiosqlite.Connection, key: str) -> None:
"""Remove a persisted setting (reverts to default)."""
await db.execute("DELETE FROM config WHERE key = ?", (key,))
await db.commit()
def apply_persisted_to_config(config, settings: dict) -> None:
"""Apply persisted settings to the live AppConfig object.
Only applies values for keys that exist in settings dict.
Does NOT overwrite values that were explicitly set via env vars.
"""
if "welcome_message" in settings:
config.ui.welcome_message = settings["welcome_message"]
if "max_concurrent" in settings:
config.downloads.max_concurrent = settings["max_concurrent"]
if "session_mode" in settings:
config.session.mode = settings["session_mode"]
if "session_timeout_hours" in settings:
config.session.timeout_hours = settings["session_timeout_hours"]
if "admin_username" in settings:
config.admin.username = settings["admin_username"]
if "admin_password_hash" in settings:
config.admin.password_hash = settings["admin_password_hash"]
if "purge_enabled" in settings:
config.purge.enabled = settings["purge_enabled"]
if "purge_max_age_hours" in settings:
config.purge.max_age_hours = settings["purge_max_age_hours"]
if "privacy_mode" in settings:
config.purge.privacy_mode = settings["privacy_mode"]
if "privacy_retention_hours" in settings:
config.purge.privacy_retention_hours = settings["privacy_retention_hours"]
logger.info("Applied %d persisted settings to config", len(settings))
def _deserialize(key: str, raw: str) -> object:
"""Deserialize a config value from its JSON string."""
try:
value = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return raw
# Type coercion for known keys
bool_keys = {"privacy_mode", "purge_enabled"}
int_keys = {"max_concurrent", "session_timeout_hours", "purge_max_age_hours", "privacy_retention_hours"}
if key in bool_keys:
return bool(value)
if key in int_keys:
return int(value) if value is not None else value
return value