fix profile-scoped backups and shared profile rules
This commit is contained in:
+140
-38
@@ -15,27 +15,46 @@ APP_BACKUP_TABLES = [
|
||||
"rtorrent_config_overrides", "poller_settings", "app_settings", "download_plan_settings",
|
||||
]
|
||||
|
||||
# Note: Profile backups contain active profile data. User-specific preferences remain scoped to the current user.
|
||||
# Note: Profile backups contain profile behavior plus user-specific view preferences for the user creating the backup.
|
||||
PROFILE_BACKUP_TABLES = [
|
||||
"rtorrent_profiles", "profile_preferences", "disk_monitor_preferences", "labels", "ratio_groups",
|
||||
"rss_feeds", "rss_rules", "smart_queue_settings", "smart_queue_exclusions",
|
||||
"automation_rules", "rtorrent_config_overrides", "poller_settings", "download_plan_settings",
|
||||
]
|
||||
|
||||
# Scope values:
|
||||
# - profile: shared profile behavior, visible/restored by profile access.
|
||||
# - user_profile: personal preferences for the backup creator/restorer.
|
||||
PROFILE_TABLE_SCOPES = {
|
||||
"rtorrent_profiles": "profile_id",
|
||||
"profile_preferences": "user_profile",
|
||||
"disk_monitor_preferences": "user_profile",
|
||||
"labels": "profile",
|
||||
"ratio_groups": "profile",
|
||||
"rss_feeds": "profile",
|
||||
"rss_rules": "profile",
|
||||
"smart_queue_settings": "profile",
|
||||
"smart_queue_exclusions": "profile",
|
||||
"automation_rules": "profile",
|
||||
"rtorrent_config_overrides": "profile",
|
||||
"poller_settings": "profile",
|
||||
"download_plan_settings": "profile_singleton",
|
||||
}
|
||||
|
||||
PROFILE_TABLE_FILTERS = {
|
||||
"rtorrent_profiles": "id=?",
|
||||
"profile_preferences": "user_id=? AND profile_id=?",
|
||||
"disk_monitor_preferences": "user_id=? AND profile_id=?",
|
||||
"labels": "user_id=? AND profile_id=?",
|
||||
"ratio_groups": "user_id=? AND profile_id=?",
|
||||
"labels": "profile_id=?",
|
||||
"ratio_groups": "profile_id=?",
|
||||
"rss_feeds": "profile_id=?",
|
||||
"rss_rules": "profile_id=?",
|
||||
"smart_queue_settings": "profile_id=?",
|
||||
"smart_queue_exclusions": "profile_id=?",
|
||||
"automation_rules": "user_id=? AND profile_id=?",
|
||||
"automation_rules": "profile_id=?",
|
||||
"rtorrent_config_overrides": "profile_id=?",
|
||||
"poller_settings": "profile_id=?",
|
||||
"download_plan_settings": "user_id=? AND profile_id=?",
|
||||
"download_plan_settings": "profile_id=?",
|
||||
}
|
||||
|
||||
DEFAULT_AUTO_BACKUP_SETTINGS = {
|
||||
@@ -91,6 +110,41 @@ def _table_rows(conn, table: str, where: str | None = None, params: tuple = ())
|
||||
return []
|
||||
|
||||
|
||||
def _profile_filter_params(table: str, user_id: int, profile_id: int) -> tuple[object, ...]:
|
||||
scope = PROFILE_TABLE_SCOPES.get(table)
|
||||
if scope in {"profile", "profile_id", "profile_singleton"}:
|
||||
return (int(profile_id),)
|
||||
return (int(user_id), int(profile_id))
|
||||
|
||||
|
||||
def _user_label(conn, user_id: int | None) -> str:
|
||||
if not user_id:
|
||||
return "system"
|
||||
try:
|
||||
row = conn.execute("SELECT display_name, username, email FROM users WHERE id=?", (int(user_id),)).fetchone()
|
||||
if row:
|
||||
return str(row.get("display_name") or row.get("username") or row.get("email") or f"user {user_id}")
|
||||
except Exception:
|
||||
pass
|
||||
return f"user {user_id}"
|
||||
|
||||
|
||||
def _backup_row_visible(row: dict, user_id: int) -> bool:
|
||||
backup_type = str(row.get("backup_type") or "app")
|
||||
if backup_type == "app":
|
||||
return _is_admin_user(user_id)
|
||||
profile_id = int(row.get("profile_id") or 0)
|
||||
return bool(profile_id and auth.can_access_profile(profile_id, user_id))
|
||||
|
||||
|
||||
def _backup_row_writable(row: dict, user_id: int) -> bool:
|
||||
backup_type = str(row.get("backup_type") or "app")
|
||||
if backup_type == "app":
|
||||
return _is_admin_user(user_id)
|
||||
profile_id = int(row.get("profile_id") or 0)
|
||||
return bool(profile_id and auth.can_write_profile(profile_id, user_id))
|
||||
|
||||
|
||||
def _store_backup(user_id: int, name: str, backup_type: str, profile_id: int | None, payload: dict) -> dict:
|
||||
with connect() as conn:
|
||||
cur = conn.execute(
|
||||
@@ -127,11 +181,7 @@ def create_profile_backup(name: str, profile_id: int, user_id: int | None = None
|
||||
with connect() as conn:
|
||||
for table in PROFILE_BACKUP_TABLES:
|
||||
where = PROFILE_TABLE_FILTERS.get(table)
|
||||
if where == "id=?" or where == "profile_id=?":
|
||||
params = (int(profile_id),)
|
||||
else:
|
||||
params = (user_id, int(profile_id))
|
||||
payload["tables"][table] = _table_rows(conn, table, where, params)
|
||||
payload["tables"][table] = _table_rows(conn, table, where, _profile_filter_params(table, user_id, int(profile_id)))
|
||||
return _store_backup(user_id, name, "profile", int(profile_id), payload)
|
||||
|
||||
|
||||
@@ -141,26 +191,39 @@ def create_backup(name: str, user_id: int | None = None, automatic: bool = False
|
||||
|
||||
def list_backups(user_id: int | None = None, backup_type: str | None = None, profile_id: int | None = None) -> list[dict]:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
clauses = ["user_id=?"]
|
||||
params: list[object] = [user_id]
|
||||
clauses: list[str] = []
|
||||
params: list[object] = []
|
||||
if backup_type:
|
||||
clauses.append("COALESCE(backup_type,'app')=?")
|
||||
params.append(backup_type)
|
||||
if profile_id is not None:
|
||||
clauses.append("profile_id=?")
|
||||
params.append(int(profile_id))
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT id,name,created_at,payload_json,COALESCE(backup_type,'app') AS backup_type,profile_id FROM app_backups WHERE {' AND '.join(clauses)} ORDER BY id DESC",
|
||||
f"""
|
||||
SELECT b.id,b.name,b.user_id,b.created_at,b.payload_json,COALESCE(b.backup_type,'app') AS backup_type,b.profile_id,
|
||||
u.display_name AS owner_display_name,u.username AS owner_username,u.email AS owner_email
|
||||
FROM app_backups b
|
||||
LEFT JOIN users u ON u.id=b.user_id
|
||||
{where}
|
||||
ORDER BY b.id DESC
|
||||
""",
|
||||
tuple(params),
|
||||
).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
if not _backup_row_visible(row, user_id):
|
||||
continue
|
||||
payload = _loads(row.get("payload_json") or "{}")
|
||||
tables = payload.get("tables") or {}
|
||||
owner_name = str(row.get("owner_display_name") or row.get("owner_username") or row.get("owner_email") or f"user {row.get('user_id')}")
|
||||
result.append({
|
||||
"id": row.get("id"),
|
||||
"name": row.get("name"),
|
||||
"owner_user_id": row.get("user_id"),
|
||||
"owner_name": owner_name,
|
||||
"created_at": row.get("created_at"),
|
||||
"backup_type": row.get("backup_type") or payload.get("backup_type") or "app",
|
||||
"profile_id": row.get("profile_id") or payload.get("source_profile_id"),
|
||||
@@ -169,16 +232,14 @@ def list_backups(user_id: int | None = None, backup_type: str | None = None, pro
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def payload_for_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
def payload_for_backup(backup_id: int, user_id: int | None = None, require_write: bool = False) -> dict:
|
||||
user_id = user_id or auth.current_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:
|
||||
row = conn.execute("SELECT id,user_id,COALESCE(backup_type,'app') AS backup_type,profile_id,payload_json FROM app_backups WHERE id=?", (backup_id,)).fetchone()
|
||||
if not row or not (_backup_row_writable(row, user_id) if require_write else _backup_row_visible(row, user_id)):
|
||||
raise ValueError("Backup not found")
|
||||
return json.loads(row["payload_json"] or "{}")
|
||||
|
||||
|
||||
def _backup_type(payload: dict) -> str:
|
||||
return str(payload.get("backup_type") or ("profile" if payload.get("source_profile_id") else "app"))
|
||||
|
||||
@@ -186,7 +247,7 @@ def _backup_type(payload: dict) -> str:
|
||||
def restore_app_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
_require_admin(user_id)
|
||||
payload = payload_for_backup(backup_id, user_id)
|
||||
payload = payload_for_backup(backup_id, user_id, require_write=True)
|
||||
if _backup_type(payload) != "app":
|
||||
raise ValueError("This is not an application backup")
|
||||
tables = payload.get("tables") or {}
|
||||
@@ -234,7 +295,7 @@ def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
if not auth.can_write_profile(target_profile_id, user_id):
|
||||
raise PermissionError("No write access to profile")
|
||||
payload = payload_for_backup(backup_id, user_id)
|
||||
payload = payload_for_backup(backup_id, user_id, require_write=True)
|
||||
if _backup_type(payload) != "profile":
|
||||
raise ValueError("This is not a profile backup")
|
||||
tables = payload.get("tables") or {}
|
||||
@@ -245,10 +306,7 @@ def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int
|
||||
for table in PROFILE_BACKUP_TABLES:
|
||||
rows = tables.get(table) or []
|
||||
where = PROFILE_TABLE_FILTERS.get(table)
|
||||
if where == "id=?" or where == "profile_id=?":
|
||||
params = (int(target_profile_id),)
|
||||
else:
|
||||
params = (user_id, int(target_profile_id))
|
||||
params = _profile_filter_params(table, user_id, int(target_profile_id))
|
||||
conn.execute(f"DELETE FROM {table} WHERE {where}", params)
|
||||
if not rows:
|
||||
continue
|
||||
@@ -269,7 +327,7 @@ def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int
|
||||
|
||||
|
||||
def restore_backup(backup_id: int, user_id: int | None = None, profile_id: int | None = None) -> dict:
|
||||
payload = payload_for_backup(backup_id, user_id)
|
||||
payload = payload_for_backup(backup_id, user_id, require_write=True)
|
||||
if _backup_type(payload) == "profile":
|
||||
target = profile_id or payload.get("source_profile_id")
|
||||
if not target:
|
||||
@@ -281,26 +339,30 @@ def restore_backup(backup_id: int, user_id: int | None = None, profile_id: int |
|
||||
def delete_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or auth.current_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))
|
||||
row = conn.execute("SELECT id,user_id,COALESCE(backup_type,'app') AS backup_type,profile_id FROM app_backups WHERE id=?", (backup_id,)).fetchone()
|
||||
if not row or not _backup_row_writable(row, user_id):
|
||||
raise ValueError("Backup not found")
|
||||
cur = conn.execute("DELETE FROM app_backups WHERE id=?", (backup_id,))
|
||||
if not cur.rowcount:
|
||||
raise ValueError("Backup not found")
|
||||
return {"deleted": backup_id}
|
||||
|
||||
|
||||
def _settings_row_key(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> str:
|
||||
uid = user_id or auth.current_user_id() or default_user_id()
|
||||
scope = "profile" if backup_type == "profile" else "app"
|
||||
if scope == "profile":
|
||||
return f"{AUTO_BACKUP_SETTINGS_KEY}:profile:{uid}:{int(profile_id or 0)}"
|
||||
return f"{AUTO_BACKUP_SETTINGS_KEY}:profile:{int(profile_id or 0)}"
|
||||
return f"{AUTO_BACKUP_SETTINGS_KEY}:app:{uid}"
|
||||
|
||||
|
||||
def _latest_backup_created_at(user_id: int, backup_type: str = "app", profile_id: int | None = None) -> str | None:
|
||||
clauses = ["user_id=?", "COALESCE(backup_type,'app')=?"]
|
||||
params: list[object] = [user_id, backup_type]
|
||||
clauses = ["COALESCE(backup_type,'app')=?"]
|
||||
params: list[object] = [backup_type]
|
||||
if backup_type == "profile":
|
||||
clauses.append("profile_id=?")
|
||||
params.append(int(profile_id or 0))
|
||||
else:
|
||||
clauses.append("user_id=?")
|
||||
params.append(user_id)
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
f"SELECT created_at FROM app_backups WHERE {' AND '.join(clauses)} ORDER BY created_at DESC, id DESC LIMIT 1",
|
||||
@@ -308,7 +370,6 @@ def _latest_backup_created_at(user_id: int, backup_type: str = "app", profile_id
|
||||
).fetchone()
|
||||
return str(row["created_at"] or "") if row and row.get("created_at") else None
|
||||
|
||||
|
||||
def _preview_value(value: object) -> object:
|
||||
if value is None or isinstance(value, (int, float, bool)):
|
||||
return value
|
||||
@@ -325,9 +386,13 @@ def _preview_row(row: dict) -> dict:
|
||||
|
||||
|
||||
def get_auto_backup_settings(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
key = _settings_row_key(user_id, backup_type, profile_id)
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
|
||||
if not row and backup_type == "profile":
|
||||
legacy_key = f"{AUTO_BACKUP_SETTINGS_KEY}:profile:{int(user_id)}:{int(profile_id or 0)}"
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (legacy_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))
|
||||
@@ -335,6 +400,9 @@ def get_auto_backup_settings(user_id: int | None = None, backup_type: str = "app
|
||||
settings["backup_type"] = "profile" if backup_type == "profile" else "app"
|
||||
if backup_type == "profile":
|
||||
settings["profile_id"] = int(profile_id or 0)
|
||||
settings["owner_user_id"] = user_id or auth.current_user_id() or default_user_id()
|
||||
with connect() as conn:
|
||||
settings["owner_name"] = _user_label(conn, settings["owner_user_id"])
|
||||
return settings
|
||||
|
||||
|
||||
@@ -361,11 +429,28 @@ def save_auto_backup_settings(data: dict, user_id: int | None = None, backup_typ
|
||||
return settings
|
||||
|
||||
|
||||
|
||||
def _backup_owner_info(backup_id: int) -> dict:
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT b.user_id,COALESCE(u.display_name,u.username,u.email,'user ' || b.user_id) AS owner_name
|
||||
FROM app_backups b
|
||||
LEFT JOIN users u ON u.id=b.user_id
|
||||
WHERE b.id=?
|
||||
""",
|
||||
(int(backup_id),),
|
||||
).fetchone()
|
||||
return {"owner_user_id": row.get("user_id") if row else None, "owner_name": row.get("owner_name") if row else ""}
|
||||
|
||||
def preview_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
payload = payload_for_backup(backup_id, user_id)
|
||||
tables = payload.get("tables") or {}
|
||||
owner = _backup_owner_info(backup_id)
|
||||
return {
|
||||
"version": payload.get("version"),
|
||||
"owner_user_id": owner.get("owner_user_id"),
|
||||
"owner_name": owner.get("owner_name"),
|
||||
"created_at": payload.get("created_at"),
|
||||
"backup_type": _backup_type(payload),
|
||||
"source_profile_id": payload.get("source_profile_id"),
|
||||
@@ -385,16 +470,18 @@ def preview_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
def prune_old_backups(user_id: int | None = None, retention_days: int = 30, backup_type: str = "app", profile_id: int | None = None) -> int:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=max(1, int(retention_days)))).isoformat(timespec="seconds")
|
||||
clauses = ["user_id=?", "COALESCE(backup_type,'app')=?", "created_at<?"]
|
||||
params: list[object] = [user_id, backup_type, cutoff]
|
||||
clauses = ["COALESCE(backup_type,'app')=?", "created_at<?"]
|
||||
params: list[object] = [backup_type, cutoff]
|
||||
if backup_type == "profile":
|
||||
clauses.append("profile_id=?")
|
||||
params.append(int(profile_id or 0))
|
||||
else:
|
||||
clauses.append("user_id=?")
|
||||
params.append(user_id)
|
||||
with connect() as conn:
|
||||
cur = conn.execute(f"DELETE FROM app_backups WHERE {' AND '.join(clauses)}", tuple(params))
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
|
||||
def _should_run(settings: dict, last_value: str | None) -> bool:
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
@@ -433,18 +520,33 @@ def maybe_create_automatic_backup(user_id: int | None = None, backup_type: str =
|
||||
|
||||
def _profile_schedule_keys() -> list[tuple[int, int]]:
|
||||
prefix = f"{AUTO_BACKUP_SETTINGS_KEY}:profile:"
|
||||
keys: list[tuple[int, int]] = []
|
||||
keys: set[tuple[int, int]] = set()
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT key FROM app_settings WHERE key LIKE ?", (prefix + "%",)).fetchall()
|
||||
for row in rows:
|
||||
parts = str(row.get("key") or "").split(":")
|
||||
try:
|
||||
keys.append((int(parts[-2]), int(parts[-1])))
|
||||
if len(parts) >= 5:
|
||||
# Legacy key: backup:auto:profile:{uid}:{profile_id}
|
||||
keys.add((int(parts[-2]), int(parts[-1])))
|
||||
elif len(parts) >= 4:
|
||||
profile_id = int(parts[-1])
|
||||
keys.add((_profile_owner_for_backup(profile_id), profile_id))
|
||||
except Exception:
|
||||
continue
|
||||
return keys
|
||||
return sorted(keys)
|
||||
|
||||
|
||||
def _profile_owner_for_backup(profile_id: int) -> int:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT user_id FROM rtorrent_profiles WHERE id=?", (int(profile_id),)).fetchone()
|
||||
if row and row.get("user_id"):
|
||||
return int(row["user_id"])
|
||||
row = conn.execute("SELECT user_id FROM user_profile_permissions WHERE profile_id=? AND access_level='full' ORDER BY user_id LIMIT 1", (int(profile_id),)).fetchone()
|
||||
if row and row.get("user_id"):
|
||||
return int(row["user_id"])
|
||||
return default_user_id()
|
||||
|
||||
def start_scheduler() -> None:
|
||||
global _scheduler_started
|
||||
with _scheduler_lock:
|
||||
|
||||
Reference in New Issue
Block a user