from __future__ import annotations import json import threading import time from datetime import datetime, timedelta, timezone from ..db import connect, utcnow, default_user_id # Note: Settings backups include persistent configuration tables only; volatile queues, caches, histories and tokens are intentionally skipped. BACKUP_TABLES = [ "users", "user_profile_permissions", "user_preferences", "rtorrent_profiles", "disk_monitor_preferences", "labels", "ratio_groups", "rss_feeds", "rss_rules", "smart_queue_settings", "smart_queue_exclusions", "automation_rules", "rtorrent_config_overrides", "app_settings", "download_plan_settings", ] DEFAULT_AUTO_BACKUP_SETTINGS = { "enabled": False, "interval_hours": 24, "retention_days": 30, "last_run_at": None, } BACKUP_PREVIEW_VALUE_LIMIT = 80 BACKUP_PREVIEW_ROW_LIMIT = 3 BACKUP_PREVIEW_SENSITIVE_KEYS = { "password", "password_hash", "token", "token_hash", "api_key", "secret", } AUTO_BACKUP_SETTINGS_KEY = "backup:auto" _scheduler_started = False _scheduler_lock = threading.Lock() def create_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict: """Create a settings backup and return a table-count summary. Note: The automatic flag is metadata only; restore/download behavior remains unchanged. """ user_id = user_id or default_user_id() payload = {"version": 1, "created_at": utcnow(), "automatic": bool(automatic), "tables": {}} with connect() as conn: for table in BACKUP_TABLES: try: payload["tables"][table] = conn.execute(f"SELECT * FROM {table}").fetchall() except Exception: payload["tables"][table] = [] cur = conn.execute( "INSERT INTO app_backups(user_id,name,payload_json,created_at) VALUES(?,?,?,?)", (user_id, name or f"Backup {payload['created_at']}", json.dumps(payload), payload["created_at"]), ) backup_id = cur.lastrowid return {"id": backup_id, "name": name, "created_at": payload["created_at"], "automatic": bool(automatic), "tables": {k: len(v) for k, v in payload["tables"].items()}} def list_backups(user_id: int | None = None) -> list[dict]: user_id = user_id or default_user_id() with connect() as conn: rows = conn.execute("SELECT id,name,created_at,payload_json FROM app_backups WHERE user_id=? ORDER BY id DESC", (user_id,)).fetchall() result = [] for row in rows: payload = _loads(row.get("payload_json") or "{}") tables = payload.get("tables") or {} result.append({ "id": row.get("id"), "name": row.get("name"), "created_at": row.get("created_at"), "automatic": bool(payload.get("automatic")), "tables": {key: len(value or []) for key, value in tables.items()}, }) return result def payload_for_backup(backup_id: int, user_id: int | None = None) -> dict: user_id = user_id or default_user_id() with connect() as conn: row = conn.execute("SELECT payload_json FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id)).fetchone() if not row: raise ValueError("Backup not found") return json.loads(row["payload_json"] or "{}") def restore_backup(backup_id: int, user_id: int | None = None) -> dict: user_id = user_id or default_user_id() payload = payload_for_backup(backup_id, user_id) tables = payload.get("tables") or {} restored = {} with connect() as conn: conn.execute("PRAGMA foreign_keys = OFF") try: for table in BACKUP_TABLES: rows = tables.get(table) or [] if not rows: continue columns = list(rows[0].keys()) placeholders = ",".join("?" for _ in columns) conn.execute(f"DELETE FROM {table}") for row in rows: conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [row.get(col) for col in columns]) restored[table] = len(rows) finally: conn.execute("PRAGMA foreign_keys = ON") return {"restored": restored} def delete_backup(backup_id: int, user_id: int | None = None) -> dict: user_id = user_id or default_user_id() with connect() as conn: cur = conn.execute( "DELETE FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id), ) if not cur.rowcount: raise ValueError("Backup not found") return {"deleted": backup_id} def _loads(value: str) -> dict: try: data = json.loads(value or "{}") return data if isinstance(data, dict) else {} except Exception: return {} def _settings_row_key(user_id: int | None = None) -> str: return f"{AUTO_BACKUP_SETTINGS_KEY}:{user_id or default_user_id()}" def _latest_backup_created_at(user_id: int) -> str | None: """Return the newest persisted backup timestamp for scheduler recovery after restarts. Note: Automatic scheduling is based on the latest database backup record, so process restarts cannot create repeated backups before the configured interval elapses. """ with connect() as conn: row = conn.execute( "SELECT created_at FROM app_backups WHERE user_id=? ORDER BY created_at DESC, id DESC LIMIT 1", (user_id,), ).fetchone() return str(row["created_at"] or "") if row and row.get("created_at") else None def _preview_value(value: object) -> object: """Return a safe, compact value for backup previews without exposing secrets.""" if value is None or isinstance(value, (int, float, bool)): return value text = str(value) return text if len(text) <= BACKUP_PREVIEW_VALUE_LIMIT else f"{text[:BACKUP_PREVIEW_VALUE_LIMIT]}..." def _preview_row(row: dict) -> dict: output = {} for key, value in row.items(): lowered = str(key).lower() if any(secret in lowered for secret in BACKUP_PREVIEW_SENSITIVE_KEYS): output[key] = "[hidden]" else: output[key] = _preview_value(value) return output def get_auto_backup_settings(user_id: int | None = None) -> dict: """Return automatic backup schedule settings for the current user. Note: The UI uses this as the single source for interval and retention controls. """ key = _settings_row_key(user_id) with connect() as conn: row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone() settings = {**DEFAULT_AUTO_BACKUP_SETTINGS, **_loads(row.get("value") if row else "{}")} settings["enabled"] = bool(settings.get("enabled")) settings["interval_hours"] = max(1, int(settings.get("interval_hours") or 24)) settings["retention_days"] = max(1, int(settings.get("retention_days") or 30)) return settings def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict: """Persist automatic backup schedule settings after validating UI input. Note: Minimum interval is one hour to avoid creating excessive database rows. """ current = get_auto_backup_settings(user_id) settings = { **current, "enabled": bool(data.get("enabled")), "interval_hours": max(1, int(data.get("interval_hours") or current["interval_hours"])), "retention_days": max(1, int(data.get("retention_days") or current["retention_days"])), "last_run_at": data.get("last_run_at", current.get("last_run_at")), } key = _settings_row_key(user_id) with connect() as conn: conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, json.dumps(settings))) return settings def preview_backup(backup_id: int, user_id: int | None = None) -> dict: """Return a compact backup preview without exposing the full JSON payload in the list view. Note: The preview shows included tables and example keys so users can verify settings coverage. """ payload = payload_for_backup(backup_id, user_id) tables = payload.get("tables") or {} return { "version": payload.get("version"), "created_at": payload.get("created_at"), "automatic": bool(payload.get("automatic")), "tables": [ { "name": table, "rows": len(rows or []), "columns": list((rows[0] or {}).keys()) if rows else [], "sample": [_preview_row(dict(row)) for row in (rows or [])[:BACKUP_PREVIEW_ROW_LIMIT]], } for table, rows in tables.items() ], } def prune_old_backups(user_id: int | None = None, retention_days: int = 30) -> int: """Delete backups older than the configured retention window for the selected user. Note: Retention is applied only to backup records, not to restored application settings. """ user_id = user_id or default_user_id() cutoff = (datetime.now(timezone.utc) - timedelta(days=max(1, int(retention_days)))).isoformat(timespec="seconds") with connect() as conn: cur = conn.execute("DELETE FROM app_backups WHERE user_id=? AND created_at dict | None: """Create an automatic backup when the saved interval has elapsed. Note: The scheduler calls this periodically, while the UI controls the interval and retention values. """ user_id = user_id or default_user_id() settings = get_auto_backup_settings(user_id) if not settings.get("enabled"): return None now = datetime.now(timezone.utc) last_value = settings.get("last_run_at") or _latest_backup_created_at(user_id) try: last = datetime.fromisoformat(str(last_value).replace("Z", "+00:00")) if last_value else None except Exception: last = None if last and now - last < timedelta(hours=settings["interval_hours"]): if settings.get("last_run_at") != last_value: settings["last_run_at"] = last_value save_auto_backup_settings(settings, user_id) return None backup = create_backup(f"Automatic backup {now.isoformat(timespec='seconds')}", user_id, automatic=True) settings["last_run_at"] = backup.get("created_at") or now.isoformat(timespec="seconds") save_auto_backup_settings(settings, user_id) prune_old_backups(user_id, settings["retention_days"]) return backup def start_scheduler() -> None: """Start a lightweight automatic-backup scheduler. Note: It scans configured users and never blocks normal request handling. """ global _scheduler_started with _scheduler_lock: if _scheduler_started: return _scheduler_started = True def loop() -> None: while True: try: with connect() as conn: rows = conn.execute("SELECT id FROM users WHERE is_active=1").fetchall() user_ids = [int(row["id"]) for row in rows] or [default_user_id()] for uid in user_ids: maybe_create_automatic_backup(uid) except Exception: pass time.sleep(300) threading.Thread(target=loop, daemon=True, name="pytorrent-backup-scheduler").start()