Files
BackItUp/app/config_store.py
T

611 lines
23 KiB
Python
Raw Normal View History

2026-07-29 17:36:22 +02:00
import sqlite3
from pathlib import Path
from app.config_model import AppConfig, BackupExecutionJob, BackupJob, BackupSummary, UserAccount
from app.crypto import CryptoManager
class ConfigStore:
def __init__(self, db_path: Path) -> None:
self.db_path = db_path
self.crypto = CryptoManager(db_path.parent / "secret.key")
def _connect(self) -> sqlite3.Connection:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def ensure_schema(self) -> None:
with self._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS app_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
ip TEXT,
port INTEGER,
debug INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS backups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
source_protocol TEXT NOT NULL,
source_host TEXT NOT NULL,
source_port INTEGER,
source_share TEXT NOT NULL,
source_subpath TEXT NOT NULL,
source_username TEXT NOT NULL,
source_password_enc TEXT NOT NULL,
destination_protocol TEXT NOT NULL,
destination_host TEXT NOT NULL,
destination_port INTEGER,
destination_share TEXT NOT NULL,
destination_subpath TEXT NOT NULL,
destination_username TEXT NOT NULL,
destination_password_enc TEXT NOT NULL,
source_entry_type TEXT NOT NULL DEFAULT 'directory',
target_kind TEXT NOT NULL DEFAULT 'folder',
target_pattern TEXT NOT NULL,
compression_method TEXT NOT NULL,
encryption_mode TEXT NOT NULL DEFAULT 'none',
archive_password_enc TEXT NOT NULL,
schedule_mode TEXT NOT NULL DEFAULT 'daily',
schedule_time TEXT NOT NULL DEFAULT '02:00',
schedule_weekday TEXT NOT NULL DEFAULT '1',
schedule_day_of_month TEXT NOT NULL DEFAULT '1',
schedule_interval INTEGER,
schedule_cron TEXT NOT NULL DEFAULT '',
last_run_at TEXT,
last_size_bytes INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
"""
)
backup_columns = {
row["name"].lower()
for row in conn.execute("PRAGMA table_info(backups)").fetchall()
}
if "source_port" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN source_port INTEGER")
if "destination_port" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN destination_port INTEGER")
if "source_entry_type" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN source_entry_type TEXT NOT NULL DEFAULT 'directory'")
if "target_kind" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN target_kind TEXT NOT NULL DEFAULT 'folder'")
if "encryption_mode" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN encryption_mode TEXT NOT NULL DEFAULT 'none'")
if "schedule_mode" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN schedule_mode TEXT NOT NULL DEFAULT 'daily'")
if "schedule_time" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN schedule_time TEXT NOT NULL DEFAULT '02:00'")
if "schedule_weekday" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN schedule_weekday TEXT NOT NULL DEFAULT '1'")
if "schedule_day_of_month" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN schedule_day_of_month TEXT NOT NULL DEFAULT '1'")
if "schedule_interval" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN schedule_interval INTEGER")
if "schedule_cron" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN schedule_cron TEXT NOT NULL DEFAULT ''")
columns = {
row["name"].lower()
for row in conn.execute("PRAGMA table_info(app_config)").fetchall()
}
# Legacy-Felder koennen in bestehenden Datenbanken fehlen/enthalten.
# Falls vorhanden, werden sie fuer Migration gelesen, danach nicht mehr genutzt.
if "adminuser" not in columns:
conn.execute("ALTER TABLE app_config ADD COLUMN adminuser TEXT")
if "adminpassword" not in columns:
conn.execute("ALTER TABLE app_config ADD COLUMN adminpassword TEXT")
conn.commit()
self._migrate_legacy_admin_if_needed()
def _migrate_legacy_admin_if_needed(self) -> None:
with self._connect() as conn:
user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if user_count > 0:
return
row = conn.execute(
"SELECT adminuser, adminpassword FROM app_config WHERE id = 1"
).fetchone()
if row is None:
return
legacy_user = row["adminuser"]
legacy_hash = row["adminpassword"]
if not isinstance(legacy_user, str) or not legacy_user.strip():
return
if not isinstance(legacy_hash, str) or not legacy_hash.strip():
return
conn.execute(
"""
INSERT INTO users (username, password_hash, is_admin, updated_at)
VALUES (?, ?, 1, CURRENT_TIMESTAMP)
""",
(legacy_user.strip(), legacy_hash.strip()),
)
conn.commit()
def load_config(self) -> AppConfig | None:
with self._connect() as conn:
row = conn.execute(
"SELECT ip, port, debug "
"FROM app_config WHERE id = 1"
).fetchone()
if row is None:
return None
ip = row["ip"]
port = row["port"]
debug = row["debug"]
if not isinstance(ip, str) or not ip.strip():
return None
if not isinstance(port, int):
return None
if debug not in (0, 1):
return None
return AppConfig(
ip=ip.strip(),
port=port,
debug=bool(debug),
)
def save_config(self, config: AppConfig) -> None:
with self._connect() as conn:
conn.execute(
"""
INSERT INTO app_config (id, ip, port, debug, updated_at)
VALUES (1, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
ip = excluded.ip,
port = excluded.port,
debug = excluded.debug,
updated_at = CURRENT_TIMESTAMP
""",
config.as_db_tuple(),
)
conn.commit()
def has_any_user(self) -> bool:
with self._connect() as conn:
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
return count > 0
def create_user(self, username: str, password_hash: str, is_admin: bool = False) -> bool:
name = username.strip()
if not name:
return False
with self._connect() as conn:
try:
conn.execute(
"""
INSERT INTO users (username, password_hash, is_admin, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
""",
(name, password_hash, 1 if is_admin else 0),
)
conn.commit()
return True
except sqlite3.IntegrityError:
return False
def list_users(self) -> list[UserAccount]:
with self._connect() as conn:
rows = conn.execute(
"SELECT username, password_hash, is_admin FROM users ORDER BY username ASC"
).fetchall()
return [
UserAccount(
username=row["username"],
password_hash=row["password_hash"],
is_admin=bool(row["is_admin"]),
)
for row in rows
]
def get_user(self, username: str) -> UserAccount | None:
with self._connect() as conn:
row = conn.execute(
"SELECT username, password_hash, is_admin FROM users WHERE username = ?",
(username.strip(),),
).fetchone()
if row is None:
return None
return UserAccount(
username=row["username"],
password_hash=row["password_hash"],
is_admin=bool(row["is_admin"]),
)
def update_user_credentials(
self,
current_username: str,
new_username: str,
new_password_hash: str,
) -> bool:
with self._connect() as conn:
try:
result = conn.execute(
"""
UPDATE users
SET username = ?, password_hash = ?, updated_at = CURRENT_TIMESTAMP
WHERE username = ?
""",
(new_username.strip(), new_password_hash, current_username.strip()),
)
conn.commit()
except sqlite3.IntegrityError:
return False
return result.rowcount == 1
def create_backup(self, backup: BackupJob) -> bool:
with self._connect() as conn:
try:
conn.execute(
"""
INSERT INTO backups (
name,
source_protocol,
source_host,
source_port,
source_share,
source_subpath,
source_username,
source_password_enc,
destination_protocol,
destination_host,
destination_port,
destination_share,
destination_subpath,
destination_username,
destination_password_enc,
source_entry_type,
target_kind,
target_pattern,
compression_method,
encryption_mode,
archive_password_enc,
schedule_mode,
schedule_time,
schedule_weekday,
schedule_day_of_month,
schedule_interval,
schedule_cron,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""",
(
backup.name,
backup.source_protocol,
backup.source_host,
backup.source_port,
backup.source_share,
backup.source_subpath,
backup.source_username,
self.crypto.encrypt_text(backup.source_password),
backup.destination_protocol,
backup.destination_host,
backup.destination_port,
backup.destination_share,
backup.destination_subpath,
backup.destination_username,
self.crypto.encrypt_text(backup.destination_password),
backup.source_entry_type,
backup.target_kind,
backup.target_pattern,
backup.compression_method,
backup.encryption_mode,
self.crypto.encrypt_text(backup.archive_password),
backup.schedule_mode,
backup.schedule_time,
backup.schedule_weekday,
backup.schedule_day_of_month,
backup.schedule_interval,
backup.schedule_cron,
),
)
conn.commit()
return True
except sqlite3.IntegrityError:
return False
def list_backups(self) -> list[BackupSummary]:
with self._connect() as conn:
rows = conn.execute(
"""
SELECT
id,
name,
source_protocol,
source_host,
source_port,
source_share,
source_subpath,
destination_protocol,
destination_host,
destination_port,
destination_share,
destination_subpath,
target_pattern,
compression_method,
schedule_mode,
last_run_at,
last_size_bytes
FROM backups
ORDER BY name ASC
"""
).fetchall()
items: list[BackupSummary] = []
for row in rows:
source_host = row["source_host"] or "-"
source_share = row["source_share"] or ""
source_subpath = row["source_subpath"] or "/"
destination_host = row["destination_host"] or "-"
destination_share = row["destination_share"] or ""
destination_subpath = row["destination_subpath"] or "/"
source_port = row["source_port"]
destination_port = row["destination_port"]
source_port_suffix = f":{source_port}" if source_port else ""
destination_port_suffix = f":{destination_port}" if destination_port else ""
source_label = (
f"{row['source_protocol']}://{source_host}{source_port_suffix}/"
f"{source_share}{source_subpath}"
)
destination_label = (
f"{row['destination_protocol']}://{destination_host}{destination_port_suffix}/"
f"{destination_share}{destination_subpath}"
)
items.append(
BackupSummary(
backup_id=row["id"],
name=row["name"],
source_label=source_label,
destination_label=destination_label,
target_pattern=row["target_pattern"],
compression_method=row["compression_method"],
schedule_mode=row["schedule_mode"] if row["schedule_mode"] else "daily",
last_run_at=row["last_run_at"],
last_size_bytes=row["last_size_bytes"],
)
)
return items
def get_backup_edit_data(self, backup_id: int) -> dict | None:
with self._connect() as conn:
row = conn.execute(
"""
SELECT
id,
name,
target_pattern,
compression_method,
encryption_mode,
schedule_mode,
schedule_time,
schedule_weekday,
schedule_day_of_month,
schedule_interval,
schedule_cron
FROM backups
WHERE id = ?
""",
(backup_id,),
).fetchone()
if row is None:
return None
return {
"id": row["id"],
"name": row["name"],
"target_pattern": row["target_pattern"],
"compression_method": row["compression_method"],
"encryption_mode": row["encryption_mode"],
"schedule_mode": row["schedule_mode"],
"schedule_time": row["schedule_time"],
"schedule_weekday": row["schedule_weekday"],
"schedule_day_of_month": row["schedule_day_of_month"],
"schedule_interval": row["schedule_interval"],
"schedule_cron": row["schedule_cron"],
}
def get_backup_execution_job(self, backup_id: int) -> BackupExecutionJob | None:
with self._connect() as conn:
row = conn.execute(
"""
SELECT
id,
name,
source_protocol,
source_host,
source_port,
source_share,
source_subpath,
source_username,
source_password_enc,
destination_protocol,
destination_host,
destination_port,
destination_share,
destination_subpath,
destination_username,
destination_password_enc
FROM backups
WHERE id = ?
""",
(backup_id,),
).fetchone()
if row is None:
return None
return BackupExecutionJob(
backup_id=row["id"],
name=row["name"],
source_protocol=row["source_protocol"],
source_host=row["source_host"],
source_port=row["source_port"],
source_share=row["source_share"],
source_subpath=row["source_subpath"],
source_username=row["source_username"],
source_password=self.crypto.decrypt_text(row["source_password_enc"]),
destination_protocol=row["destination_protocol"],
destination_host=row["destination_host"],
destination_port=row["destination_port"],
destination_share=row["destination_share"],
destination_subpath=row["destination_subpath"],
destination_username=row["destination_username"],
destination_password=self.crypto.decrypt_text(row["destination_password_enc"]),
)
def update_backup_edit_data(
self,
backup_id: int,
name: str,
target_pattern: str,
compression_method: str,
encryption_mode: str,
archive_password: str,
schedule_mode: str,
schedule_time: str,
schedule_weekday: str,
schedule_day_of_month: str,
schedule_interval: int | None,
schedule_cron: str,
) -> bool:
with self._connect() as conn:
try:
if archive_password:
result = conn.execute(
"""
UPDATE backups
SET
name = ?,
target_pattern = ?,
compression_method = ?,
encryption_mode = ?,
archive_password_enc = ?,
schedule_mode = ?,
schedule_time = ?,
schedule_weekday = ?,
schedule_day_of_month = ?,
schedule_interval = ?,
schedule_cron = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(
name,
target_pattern,
compression_method,
encryption_mode,
self.crypto.encrypt_text(archive_password),
schedule_mode,
schedule_time,
schedule_weekday,
schedule_day_of_month,
schedule_interval,
schedule_cron,
backup_id,
),
)
else:
result = conn.execute(
"""
UPDATE backups
SET
name = ?,
target_pattern = ?,
compression_method = ?,
encryption_mode = ?,
schedule_mode = ?,
schedule_time = ?,
schedule_weekday = ?,
schedule_day_of_month = ?,
schedule_interval = ?,
schedule_cron = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(
name,
target_pattern,
compression_method,
encryption_mode,
schedule_mode,
schedule_time,
schedule_weekday,
schedule_day_of_month,
schedule_interval,
schedule_cron,
backup_id,
),
)
conn.commit()
return result.rowcount == 1
except sqlite3.IntegrityError:
return False
def mark_backup_run(self, backup_id: int) -> bool:
with self._connect() as conn:
result = conn.execute(
"""
UPDATE backups
SET
last_run_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'now', 'localtime'),
last_size_bytes = COALESCE(last_size_bytes, 0),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(backup_id,),
)
conn.commit()
return result.rowcount == 1
def delete_backup(self, backup_id: int) -> bool:
with self._connect() as conn:
result = conn.execute("DELETE FROM backups WHERE id = ?", (backup_id,))
conn.commit()
return result.rowcount == 1