first commit
This commit is contained in:
286
pytorrent/services/backup.py
Normal file
286
pytorrent/services/backup.py
Normal file
@@ -0,0 +1,286 @@
|
||||
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<?", (user_id, cutoff))
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
|
||||
def maybe_create_automatic_backup(user_id: int | None = None) -> 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()
|
||||
Reference in New Issue
Block a user