From a495570b20eb41ec24aaeb11b73b8ddf362e2823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Nehlsen?= Date: Tue, 11 Aug 2026 22:45:58 +0200 Subject: [PATCH] feat: Sync/Clone-Modus, Rotation, --setup Deployment, Scheduler-Fix, Dashboard-Updates, README --- .gitignore | 5 + README.md | 244 +++-- app/app_runner.py | 65 +- app/config_model.py | 15 +- app/config_store.py | 355 +++++-- app/remote_targets.py | 98 +- app/templates/backup_edit.html | 737 +++++++++++++-- app/templates/backup_new.html | 1114 ++++++++++++++++------ app/templates/base_admin.html | 29 +- app/templates/dashboard.html | 275 +++++- app/web_server.py | 1586 +++++++++++++++++++++++++++++--- data/config.db | Bin 28672 -> 0 bytes main.py | 7 +- requirements.txt | 2 + 14 files changed, 3851 insertions(+), 681 deletions(-) delete mode 100644 data/config.db diff --git a/.gitignore b/.gitignore index 36b13f1..bca98b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# Customs +data/config.db +data/secret.key + # ---> Python # Byte-compiled / optimized / DLL files __pycache__/ @@ -174,3 +178,4 @@ cython_debug/ # PyPI configuration file .pypirc +*.bak diff --git a/README.md b/README.md index 6158000..b4247a2 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,161 @@ # StFV Backup -## Modulares Python-Startsystem +Flask-basierte Backup-Verwaltung für SMB, LOCAL, FTP und SFTP – mit Web-UI, Zeitplan-Steuerung und Sync/Clone-Modus. -Das Projekt ist jetzt in moderne, klar getrennte Module aufgeteilt: +--- -- app/app_runner.py: Orchestrierung des kompletten Startablaufs -- app/venv_manager.py: .venv-Pruefung, Erstellung und Re-Exec -- app/dependencies.py: requirements-Parsing und Installation fehlender Pakete -- app/config_store.py: SQLite-Schema, Laden und Speichern der Konfiguration -- app/setup_wizard.py: Interaktive Erfassung von Erstkonfiguration -- app/security.py: Passwort-Hashing und Verifikation -- app/ui.py: Frische Konsolen-Ausgabe mit klaren Statusfarben -- app/paths.py: Zentrale Pfadverwaltung - -### Startlogik - -- Start ausserhalb von .venv: automatische Erstellung und Neustart in .venv -- requirements.txt wird geprueft, fehlende Pakete werden installiert -- SQLite unter data/config.db wird geprueft/erstellt -- Falls Konfiguration fehlt oder unvollstaendig ist, startet das Setup fuer: - - IP - - Port - - Debug (true/false) - - Adminuser - - Adminpassword - -### Bedeutung der Felder - -- IP, Port, Debug: Laufzeitkonfiguration fuer den Flask-Webserver. -- Adminuser, Adminpassword: Zugangsdaten fuer den Admin-Login im Browser. - -Sicherheitsaspekt: -- Das Passwort wird verdeckt eingegeben und ausschliesslich als PBKDF2-SHA256-Hash gespeichert. -- Eine Verifikationsfunktion fuer Login-Checks ist vorbereitet. - -### Browserzugriff - -- Nach dem Start laeuft Flask auf der konfigurierten Adresse, z. B. http://127.0.0.1:5000 -- Der Root-Pfad leitet auf /login um. -- Erfolgreiche Anmeldung fuehrt auf /dashboard. - -### Admin-Menue - -Nach dem Login stehen folgende Menuepunkte bereit: - -- Dashboard: Liste der eingerichteten Backups mit letztem Lauf und Groesse -- Neues Backup: SMB-Quelle/Ziel, Unterpfade, Target-Muster, Kompression und Verschluesselung -- Server Settings: IP, Port und Debug fuer den Flask-Webserver -- Admin Konto: Eigenen Admin-Benutzer und Passwort aendern -- Benutzer: Neue Benutzer anlegen (optional mit Adminrechten) - -### Backup-Konfiguration (aktuell) - -- Source/Destination starten mit einem Typ-Dropdown: SMB, LOCAL, FTP, SFTP -- Je nach Typ werden nur die passenden Felder eingeblendet -- Source: Verbindungs-Testbutton pro Konfiguration -- Destination: Verbindungs-Testbutton pro Konfiguration -- SMB Browser: Verzeichnisse fuer Source und Destination direkt durchklickbar -- Target: Frei definierbares Muster, z. B. backup_{date}_{time} -- Kompression: zip, tar.gz, tar.bz2, tar.xz, 7z -- Archiv-Passwort: wird verschluesselt gespeichert -- SMB-Passwoerter: werden verschluesselt gespeichert - -### Geplante Backup-Module - -Das Dashboard ist auf den Ausbau fuer mehrere Zielsysteme vorbereitet: - -- Nextcloud -- Unraid -- MS SQL (z. B. auf Win11 VM) -- Transport-Protokolle wie FTP, SFTP, SMB, NFS - -### Service fuer direkten Browser-Test - -Damit der Server dauerhaft laeuft und du direkt im Browser testen kannst: - -```bash -./scripts/service.sh install -./scripts/service.sh start -``` - -Nutzliche Befehle: - -```bash -./scripts/service.sh status -./scripts/service.sh restart -./scripts/service.sh stop -./scripts/service.sh logs -./scripts/service.sh enable -``` - -Service-Datei im Projekt: -- deploy/systemd/nextcloud-backup.service - -### Start +## Schnellstart ```bash +# Einmalig: Venv anlegen, Abhängigkeiten installieren, Ersteinrichtung starten python3 main.py + +# Deployment auf einem Linux-Server (systemd) +python3 main.py --setup +systemctl --user enable nextcloud-backup +systemctl --user start nextcloud-backup ``` +Nach dem Start ist die Oberfläche erreichbar unter: +`http://:/` (Standard: `http://127.0.0.1:5001/`) + +--- + +## Deployment auf Oracle Linux / RHEL + +### Voraussetzungen + +```bash +sudo dnf install python3 python3-pip git -y +# Service soll auch ohne aktive Login-Session laufen +sudo loginctl enable-linger $USER +``` + +### Projekt klonen und einrichten + +```bash +git clone https://gitea.example.com/user/stfv-backup.git ~/stfv-backup +cd ~/stfv-backup +python3 main.py --setup +``` + +`--setup` erledigt automatisch: +1. Virtuelle Umgebung unter `.venv/` anlegen +2. Abhängigkeiten installieren +3. systemd-User-Service unter `~/.config/systemd/user/nextcloud-backup.service` schreiben (mit echten Pfaden) +4. `systemctl --user daemon-reload` ausführen + +### Service aktivieren + +```bash +systemctl --user enable nextcloud-backup +systemctl --user start nextcloud-backup +systemctl --user status nextcloud-backup +journalctl --user -u nextcloud-backup -f +``` + +### Beim ersten Start + +Beim allerersten Aufruf ohne Konfiguration fragt das System interaktiv ab: +- IP und Port des Flask-Servers +- Admin-Benutzername und Passwort (wird als PBKDF2-SHA256-Hash gespeichert) + +--- + +## Funktionsübersicht + +### Backup-Modi + +| Modus | Verhalten | +|---|---| +| **Vollständig** | Alle Dateien aus der Quelle werden jedes Mal übertragen | +| **Sync** | Nur neue oder geänderte Dateien (Vergleich per Größe + mtime) | +| **Clone** | Wie Sync, zusätzlich werden Dateien im Ziel gelöscht die in der Quelle nicht mehr existieren | + +### Unterstützte Protokolle + +- **SMB** – mit integriertem Verzeichnis-Browser und Ordner-Erstellung +- **LOCAL** – lokales Dateisystem mit Browser und Ordner-Erstellung +- **FTP** – mit konfigurierbarem Port (Standard: 21) +- **SFTP** – SSH-basiert (Standard-Port: 22) + +### Zeitplan + +- CRON-Ausdruck frei definierbar (z. B. `0 2 * * *` für täglich um 02:00 Uhr) +- CRON-Vorschau im Wizard zeigt den nächsten geplanten Zeitpunkt +- Leer lassen = nur manuelles Backup möglich +- „Nächster Lauf" im Dashboard wird nach jedem Backup automatisch aktualisiert + +### Rotation + +- Anzahl aufzubewahrender Backups konfigurierbar +- `0` = unbegrenzt (keine automatische Löschung) +- Nur bei Modus **Vollständig** verfügbar (Sync/Clone verwalten den Zustand selbst) + +### Kompression & Verschlüsselung + +| Methode | Unterstützt | +|---|---| +| Keine (1:1 Kopie) | ✅ | +| zip | ✅ | +| tar.gz / tar.bz2 / tar.xz | ✅ | +| 7z | ✅ (py7zr erforderlich) | +| AES-256 Archiv-Passwort | ✅ | + +### Sicherheit + +- Passwörter werden nie im Klartext gespeichert +- Benutzerpasswörter: PBKDF2-SHA256 +- Verbindungspasswörter: AES-verschlüsselt in der Datenbank +- Session-basierter Login mit Rollen (Admin / Benutzer) + +--- + +## Projektstruktur + +``` +main.py # Einstiegspunkt; --setup für Deployment +app/ + app_runner.py # Bootstrap, Setup-Logik, systemd-Installation + web_server.py # Flask-Routen, Backup-Ausführung, Scheduler + config_store.py # SQLite-Schema, Migrationen, CRUD + config_model.py # Dataclasses + remote_targets.py # Verbindungstests, Browser-Helfer + setup_wizard.py # Interaktive Erstkonfiguration (CLI) + venv_manager.py # Venv-Prüfung und Re-Exec + dependencies.py # Paketinstallation + security.py # Passwort-Hashing + paths.py # Zentrale Pfadverwaltung + ui.py # Konsolenausgabe + templates/ # Jinja2-Templates (Dashboard, Wizard, ...) +data/ + config.db # SQLite-Datenbank (wird automatisch angelegt) +deploy/ + systemd/ + nextcloud-backup.service # Vorlage; --setup schreibt echte Pfade +scripts/ + service.sh # Manuelle Service-Verwaltung (Alternative zu --setup) +requirements.txt +``` + +--- + +## Service manuell verwalten + +```bash +./scripts/service.sh install # Service-Datei installieren + daemon-reload +./scripts/service.sh start +./scripts/service.sh stop +./scripts/service.sh restart +./scripts/service.sh status +./scripts/service.sh logs +./scripts/service.sh enable # Autostart aktivieren +./scripts/service.sh disable +``` + +--- + +## Anforderungen + +- Python 3.10+ +- Abhängigkeiten: siehe `requirements.txt` (werden beim Start automatisch installiert) diff --git a/app/app_runner.py b/app/app_runner.py index 812e687..b10fac6 100644 --- a/app/app_runner.py +++ b/app/app_runner.py @@ -11,9 +11,8 @@ class AppRunner: self.paths = AppPaths(base_dir=base_dir) self.entry_script = entry_script - def run(self) -> None: - ConsoleUI.headline("StFV Backup Bootstrap") - + def _bootstrap(self) -> None: + """Ensure venv and dependencies are ready (re-execs into venv if needed).""" venv = VirtualEnvManager(self.paths.venv_dir, self.entry_script) if not venv.in_virtualenv(): ConsoleUI.info("Virtuelle Umgebung wird vorbereitet...") @@ -26,6 +25,66 @@ class AppRunner: else: ConsoleUI.info("Alle Abhaengigkeiten sind bereits installiert.") + def setup(self) -> None: + ConsoleUI.headline("StFV Backup Setup") + self._bootstrap() + ConsoleUI.success("System ist bereit.") + self._install_systemd_service() + + def _install_systemd_service(self) -> None: + import shutil + import subprocess + + if not shutil.which("systemctl"): + ConsoleUI.warn("systemctl nicht gefunden – kein systemd-System erkannt.") + ConsoleUI.info("Manuelle Installation: bash scripts/service.sh install") + return + + service_name = "nextcloud-backup.service" + user_systemd_dir = Path.home() / ".config" / "systemd" / "user" + user_systemd_dir.mkdir(parents=True, exist_ok=True) + + if self.paths.venv_dir.name == "nt": + venv_python = self.paths.venv_dir / "Scripts" / "python.exe" + else: + venv_python = self.paths.venv_dir / "bin" / "python" + + service_content = f"""[Unit] +Description=StFV Backup Flask Service +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory={self.paths.base_dir} +ExecStart={venv_python} {self.entry_script.resolve()} +Restart=on-failure +RestartSec=3 +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=default.target +""" + + target = user_systemd_dir / service_name + target.write_text(service_content) + ConsoleUI.success(f"Service-Datei geschrieben: {target}") + + try: + subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) + ConsoleUI.success("systemd daemon neu geladen.") + except subprocess.CalledProcessError as exc: + ConsoleUI.warn(f"daemon-reload fehlgeschlagen: {exc}") + + ConsoleUI.info(f"Service aktivieren: systemctl --user enable {service_name}") + ConsoleUI.info(f"Service starten: systemctl --user start {service_name}") + ConsoleUI.info(f"Status pruefen: systemctl --user status {service_name}") + ConsoleUI.info(f"Logs verfolgen: journalctl --user -u {service_name} -f") + + def run(self) -> None: + ConsoleUI.headline("StFV Backup Bootstrap") + self._bootstrap() + from app.config_store import ConfigStore from app.setup_wizard import SetupWizard from app.web_server import FlaskServer diff --git a/app/config_model.py b/app/config_model.py index 468c99e..03003cc 100644 --- a/app/config_model.py +++ b/app/config_model.py @@ -53,11 +53,9 @@ class BackupJob: 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 + backup_mode: str + rotation_keep: int @dataclass(frozen=True) @@ -78,6 +76,12 @@ class BackupExecutionJob: destination_subpath: str destination_username: str destination_password: str + source_entry_type: str + target_kind: str + target_pattern: str + compression_method: str + backup_mode: str + rotation_keep: int @dataclass(frozen=True) @@ -88,6 +92,7 @@ class BackupSummary: destination_label: str target_pattern: str compression_method: str - schedule_mode: str + schedule_cron: str + next_run_at: str | None last_run_at: str | None last_size_bytes: int | None diff --git a/app/config_store.py b/app/config_store.py index b131908..09f5d6d 100644 --- a/app/config_store.py +++ b/app/config_store.py @@ -69,12 +69,10 @@ class ConfigStore: 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_mode TEXT NOT NULL DEFAULT 'custom', schedule_cron TEXT NOT NULL DEFAULT '', + backup_mode TEXT NOT NULL DEFAULT 'full', + rotation_keep INTEGER NOT NULL DEFAULT 0, last_run_at TEXT, last_size_bytes INTEGER, created_at TEXT DEFAULT CURRENT_TIMESTAMP, @@ -87,6 +85,12 @@ class ConfigStore: row["name"].lower() for row in conn.execute("PRAGMA table_info(backups)").fetchall() } + if {"schedule_time", "schedule_weekday", "schedule_day_of_month", "schedule_interval"} & backup_columns: + self._migrate_legacy_schedule_columns(conn) + 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: @@ -98,17 +102,13 @@ class ConfigStore: 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") + conn.execute("ALTER TABLE backups ADD COLUMN schedule_mode TEXT NOT NULL DEFAULT 'custom'") if "schedule_cron" not in backup_columns: conn.execute("ALTER TABLE backups ADD COLUMN schedule_cron TEXT NOT NULL DEFAULT ''") + if "backup_mode" not in backup_columns: + conn.execute("ALTER TABLE backups ADD COLUMN backup_mode TEXT NOT NULL DEFAULT 'full'") + if "rotation_keep" not in backup_columns: + conn.execute("ALTER TABLE backups ADD COLUMN rotation_keep INTEGER NOT NULL DEFAULT 0") columns = { row["name"].lower() @@ -126,6 +126,109 @@ class ConfigStore: self._migrate_legacy_admin_if_needed() + def _migrate_legacy_schedule_columns(self, conn: sqlite3.Connection) -> None: + conn.execute("ALTER TABLE backups RENAME TO backups_legacy") + conn.execute( + """ + CREATE TABLE 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 'custom', + 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 + ) + """ + ) + conn.execute( + """ + INSERT INTO backups ( + 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, + source_entry_type, + target_kind, + target_pattern, + compression_method, + encryption_mode, + archive_password_enc, + schedule_mode, + schedule_cron, + last_run_at, + last_size_bytes, + created_at, + updated_at + ) + 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, + COALESCE(source_entry_type, 'directory'), + COALESCE(target_kind, 'folder'), + target_pattern, + compression_method, + COALESCE(encryption_mode, 'none'), + archive_password_enc, + 'custom', + COALESCE(schedule_cron, ''), + last_run_at, + last_size_bytes, + created_at, + updated_at + FROM backups_legacy + """ + ) + conn.execute("DROP TABLE backups_legacy") + conn.commit() + def _migrate_legacy_admin_if_needed(self) -> None: with self._connect() as conn: user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] @@ -302,14 +405,12 @@ class ConfigStore: encryption_mode, archive_password_enc, schedule_mode, - schedule_time, - schedule_weekday, - schedule_day_of_month, - schedule_interval, schedule_cron, + backup_mode, + rotation_keep, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) """, ( backup.name, @@ -334,11 +435,9 @@ class ConfigStore: 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, + backup.backup_mode, + backup.rotation_keep, ), ) conn.commit() @@ -346,6 +445,19 @@ class ConfigStore: except sqlite3.IntegrityError: return False + def _compute_next_run(self, cron_expr: str) -> str | None: + if not cron_expr: + return None + + try: + from datetime import datetime + from croniter import croniter + + next_dt = croniter(cron_expr, datetime.now()).get_next(datetime) + return next_dt.strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + def list_backups(self) -> list[BackupSummary]: with self._connect() as conn: rows = conn.execute( @@ -365,7 +477,7 @@ class ConfigStore: destination_subpath, target_pattern, compression_method, - schedule_mode, + schedule_cron, last_run_at, last_size_bytes FROM backups @@ -403,7 +515,8 @@ class ConfigStore: 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", + schedule_cron=row["schedule_cron"], + next_run_at=self._compute_next_run(row["schedule_cron"]), last_run_at=row["last_run_at"], last_size_bytes=row["last_size_bytes"], ) @@ -411,6 +524,118 @@ class ConfigStore: return items + def get_full_backup_for_edit(self, backup_id: int) -> dict | 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, + source_entry_type, target_kind, target_pattern, + compression_method, encryption_mode, archive_password_enc, + schedule_mode, schedule_cron, backup_mode, rotation_keep + FROM backups WHERE id = ? + """, + (backup_id,), + ).fetchone() + + if row is None: + return None + + is_src_local = row["source_protocol"] == "LOCAL" + is_dst_local = row["destination_protocol"] == "LOCAL" + + return { + "id": row["id"], + "name": row["name"], + "source_protocol": row["source_protocol"], + "source_host": "" if is_src_local else (row["source_host"] or ""), + "source_port": row["source_port"] or "", + "source_share": row["source_share"] or "", + "source_subpath": row["source_subpath"] or "/", + "source_username": row["source_username"] or "", + "source_password": "***" if row["source_password_enc"] else "", + "source_local_path": row["source_subpath"] if is_src_local else "", + "destination_protocol": row["destination_protocol"], + "destination_host": "" if is_dst_local else (row["destination_host"] or ""), + "destination_port": row["destination_port"] or "", + "destination_share": row["destination_share"] or "", + "destination_subpath": row["destination_subpath"] or "/", + "destination_username": row["destination_username"] or "", + "destination_password": "***" if row["destination_password_enc"] else "", + "destination_local_path": row["destination_subpath"] if is_dst_local else "", + "source_entry_type": row["source_entry_type"], + "target_kind": row["target_kind"], + "target_pattern": row["target_pattern"], + "compression_method": row["compression_method"], + "encryption_mode": row["encryption_mode"], + "archive_password": "***" if row["archive_password_enc"] else "", + "schedule_mode": row["schedule_mode"], + "schedule_cron": row["schedule_cron"] or "", + "backup_mode": row["backup_mode"] or "full", + "rotation_keep": row["rotation_keep"] or 0, + } + + def update_full_backup( + self, + backup_id: int, + name: str, + source_protocol: str, source_host: str, source_port: int | None, + source_share: str, source_subpath: str, source_username: str, + source_password: str | None, + destination_protocol: str, destination_host: str, destination_port: int | None, + destination_share: str, destination_subpath: str, destination_username: str, + destination_password: str | None, + source_entry_type: str, target_kind: str, target_pattern: str, + compression_method: str, encryption_mode: str, + archive_password: str | None, + schedule_mode: str, schedule_cron: str, + backup_mode: str = "full", + rotation_keep: int = 0, + ) -> bool: + with self._connect() as conn: + try: + row = conn.execute( + "SELECT source_password_enc, destination_password_enc, archive_password_enc FROM backups WHERE id = ?", + (backup_id,), + ).fetchone() + if row is None: + return False + + src_enc = self.crypto.encrypt_text(source_password) if source_password is not None else row["source_password_enc"] + dst_enc = self.crypto.encrypt_text(destination_password) if destination_password is not None else row["destination_password_enc"] + arc_enc = self.crypto.encrypt_text(archive_password) if archive_password is not None else row["archive_password_enc"] + + result = conn.execute( + """ + UPDATE backups SET + 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_cron=?, backup_mode=?, rotation_keep=?, updated_at=CURRENT_TIMESTAMP + WHERE id=? + """, + ( + name, source_protocol, source_host, source_port, + source_share, source_subpath, source_username, src_enc, + destination_protocol, destination_host, destination_port, + destination_share, destination_subpath, destination_username, dst_enc, + source_entry_type, target_kind, target_pattern, + compression_method, encryption_mode, arc_enc, + schedule_mode, schedule_cron, backup_mode, rotation_keep, + backup_id, + ), + ) + conn.commit() + return result.rowcount == 1 + except sqlite3.IntegrityError: + return False + def get_backup_edit_data(self, backup_id: int) -> dict | None: with self._connect() as conn: row = conn.execute( @@ -422,10 +647,6 @@ class ConfigStore: compression_method, encryption_mode, schedule_mode, - schedule_time, - schedule_weekday, - schedule_day_of_month, - schedule_interval, schedule_cron FROM backups WHERE id = ? @@ -443,10 +664,6 @@ class ConfigStore: "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"], } @@ -455,22 +672,13 @@ class ConfigStore: 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 + 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, + source_entry_type, target_kind, target_pattern, + compression_method, backup_mode, rotation_keep FROM backups WHERE id = ? """, @@ -497,6 +705,12 @@ class ConfigStore: destination_subpath=row["destination_subpath"], destination_username=row["destination_username"], destination_password=self.crypto.decrypt_text(row["destination_password_enc"]), + source_entry_type=row["source_entry_type"], + target_kind=row["target_kind"], + target_pattern=row["target_pattern"], + compression_method=row["compression_method"], + backup_mode=row["backup_mode"] or "full", + rotation_keep=row["rotation_keep"] or 0, ) def update_backup_edit_data( @@ -508,10 +722,6 @@ class ConfigStore: 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: @@ -527,10 +737,6 @@ class ConfigStore: 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 = ? @@ -542,10 +748,6 @@ class ConfigStore: encryption_mode, self.crypto.encrypt_text(archive_password), schedule_mode, - schedule_time, - schedule_weekday, - schedule_day_of_month, - schedule_interval, schedule_cron, backup_id, ), @@ -560,10 +762,6 @@ class ConfigStore: compression_method = ?, encryption_mode = ?, schedule_mode = ?, - schedule_time = ?, - schedule_weekday = ?, - schedule_day_of_month = ?, - schedule_interval = ?, schedule_cron = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? @@ -574,10 +772,6 @@ class ConfigStore: compression_method, encryption_mode, schedule_mode, - schedule_time, - schedule_weekday, - schedule_day_of_month, - schedule_interval, schedule_cron, backup_id, ), @@ -587,22 +781,39 @@ class ConfigStore: except sqlite3.IntegrityError: return False - def mark_backup_run(self, backup_id: int) -> bool: + def mark_backup_run(self, backup_id: int, size_bytes: int) -> bool: + safe_size = max(0, int(size_bytes)) 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), + last_size_bytes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? """, - (backup_id,), + (safe_size, backup_id), ) conn.commit() return result.rowcount == 1 + def get_backup_runtime_meta(self, backup_id: int) -> dict | None: + with self._connect() as conn: + row = conn.execute( + "SELECT last_run_at, last_size_bytes, schedule_cron FROM backups WHERE id = ?", + (backup_id,), + ).fetchone() + + if row is None: + return None + + return { + "last_run_at": row["last_run_at"], + "last_size_bytes": row["last_size_bytes"], + "next_run_at": self._compute_next_run(row["schedule_cron"] or ""), + } + def delete_backup(self, backup_id: int) -> bool: with self._connect() as conn: result = conn.execute("DELETE FROM backups WHERE id = ?", (backup_id,)) diff --git a/app/remote_targets.py b/app/remote_targets.py index 88de9e1..5c8d574 100644 --- a/app/remote_targets.py +++ b/app/remote_targets.py @@ -251,9 +251,9 @@ def test_connection(target: TargetConnection) -> tuple[bool, str]: if protocol == "LOCAL": check_path = Path(target.subpath) - if check_path.exists() and check_path.is_dir(): + if check_path.exists() and (check_path.is_dir() or check_path.is_file()): return True, "Lokaler Pfad erreichbar." - return False, "Lokaler Pfad ist nicht erreichbar oder kein Verzeichnis." + return False, "Lokaler Pfad nicht gefunden oder nicht zugänglich." if protocol == "SMB": if not target.share: @@ -271,7 +271,13 @@ def test_connection(target: TargetConnection) -> tuple[bool, str]: try: ftp.connect(target.host, target.port or 21, timeout=8) ftp.login(target.username, target.password) - ftp.cwd(target.subpath or "/") + subpath = target.subpath or "/" + try: + ftp.cwd(subpath) + except Exception: # noqa: BLE001 + # subpath may be a file — try parent directory + parent = "/".join(subpath.rstrip("/").split("/")[:-1]) or "/" + ftp.cwd(parent) return True, "FTP-Verbindung und Pfad sind erreichbar." except Exception as exc: # noqa: BLE001 return False, f"FTP-Test fehlgeschlagen: {exc}" @@ -288,7 +294,13 @@ def test_connection(target: TargetConnection) -> tuple[bool, str]: transport = paramiko.Transport((target.host, target.port or 22)) transport.connect(username=target.username, password=target.password) sftp = paramiko.SFTPClient.from_transport(transport) - sftp.listdir(target.subpath or "/") + subpath = target.subpath or "/" + try: + sftp.listdir(subpath) + except Exception: # noqa: BLE001 + # subpath may be a file — try parent directory + parent = "/".join(subpath.rstrip("/").split("/")[:-1]) or "/" + sftp.listdir(parent) return True, "SFTP-Verbindung und Pfad sind erreichbar." except Exception as exc: # noqa: BLE001 return False, f"SFTP-Test fehlgeschlagen: {exc}" @@ -301,6 +313,84 @@ def test_connection(target: TargetConnection) -> tuple[bool, str]: return False, "Unbekanntes Protokoll." +def browse_ftp_directories(target: TargetConnection) -> tuple[bool, str, list[dict[str, str]]]: + ftp = FTP() + try: + ftp.connect(target.host, target.port or 21, timeout=8) + ftp.login(target.username, target.password) + current_path = normalize_subpath(target.subpath or "/") + try: + ftp.cwd(current_path) + except Exception: + current_path = "/" + ftp.cwd("/") + + entries: list[dict[str, str]] = [] + if current_path != "/": + parent = "/".join(current_path.rstrip("/").split("/")[:-1]) or "/" + entries.append({"name": "..", "path": parent, "entry_type": "up"}) + + raw_lines: list[str] = [] + try: + ftp.retrlines("LIST", raw_lines.append) + except Exception: + pass + + items: list[dict[str, str]] = [] + for line in raw_lines: + parts = line.split(None, 8) + if len(parts) < 9: + continue + name = parts[8].strip() + if name in (".", ".."): + continue + is_dir = parts[0].startswith("d") + sub_path = _build_child_path(current_path, name) + items.append({"name": name, "path": sub_path, "entry_type": "directory" if is_dir else "file"}) + + entries.extend(_sort_entries(items)) + return True, current_path, entries + except Exception as exc: # noqa: BLE001 + return False, f"FTP-Fehler: {exc}", [] + finally: + try: + ftp.quit() + except Exception: # noqa: BLE001 + pass + + +def browse_sftp_directories(target: TargetConnection) -> tuple[bool, str, list[dict[str, str]]]: + import stat as stat_module + transport = None + sftp_client = None + try: + transport = paramiko.Transport((target.host, target.port or 22)) + transport.connect(username=target.username, password=target.password) + sftp_client = paramiko.SFTPClient.from_transport(transport) + current_path = normalize_subpath(target.subpath or "/") + + entries: list[dict[str, str]] = [] + if current_path != "/": + parent = "/".join(current_path.rstrip("/").split("/")[:-1]) or "/" + entries.append({"name": "..", "path": parent, "entry_type": "up"}) + + items: list[dict[str, str]] = [] + for attr in sftp_client.listdir_attr(current_path): + is_dir = stat_module.S_ISDIR(attr.st_mode or 0) + sub_path = _build_child_path(current_path, attr.filename) + items.append({"name": attr.filename, "path": sub_path, "entry_type": "directory" if is_dir else "file"}) + + entries.extend(_sort_entries(items)) + return True, current_path, entries + except Exception as exc: # noqa: BLE001 + return False, f"SFTP-Fehler: {exc}", [] + finally: + if sftp_client is not None: + sftp_client.close() + if transport is not None: + transport.close() + + def browse_smb_directories(target: TargetConnection) -> tuple[bool, str, list[dict[str, str]]]: if target.protocol.upper() != "SMB": return False, "SMB-Browser ist nur fuer SMB verfuegbar.", [] diff --git a/app/templates/backup_edit.html b/app/templates/backup_edit.html index 876167a..6399643 100644 --- a/app/templates/backup_edit.html +++ b/app/templates/backup_edit.html @@ -3,107 +3,682 @@ {% block title %}StFV Backup - Backup bearbeiten{% endblock %} {% block content %} -

Backup bearbeiten

-

Hier kannst du Name, Ziel, Verschlüsselung und Zeitplan anpassen.

+

Backup bearbeiten

-
-
- + + + + + + +
+ + + + + +
+ +
+

Allgemein

+ - - - - - - - - - - -
- - - - - +
+
-
+
-
-

Zeitplan

- - - -
- - +
+

Source

+
+
+ + +
+ + +
-
+ +
+

Destination

+
+
+ + +
+ + +
-
+ +
+

Target

+ +
+ +
+ +
-
-
- Abbrechen - -
+
+

Zeitplan

+ + + + +
+ Abbrechen + + +
+
{% endblock %} diff --git a/app/templates/backup_new.html b/app/templates/backup_new.html index da25426..6d63519 100644 --- a/app/templates/backup_new.html +++ b/app/templates/backup_new.html @@ -3,8 +3,7 @@ {% block title %}StFV Backup - Neues Backup{% endblock %} {% block content %} -

Neues Backup einrichten

-

Die Konfiguration erscheint Schritt fuer Schritt, damit du jedes Fenster nacheinander ausfuellen kannst.

+

Neues Backup einrichten

Eingerichtete Backups

@@ -49,6 +121,7 @@ Name Zeitplan + Nächster Lauf Letzter Lauf Größe Aktionen @@ -58,13 +131,28 @@ {% for backup in backups %} {{ backup.name }} - {{ backup.schedule_mode }} - {{ backup.last_run_at | fmt_dt }} - {{ backup.last_size_bytes | fmt_bytes }} + {{ backup.schedule_cron or '–' }} + {% if backup.schedule_cron %}{{ backup.next_run_at | fmt_dt }}{% else %}Kein automatisches Backup{% endif %} + {{ backup.last_run_at | fmt_dt }} + {{ backup.last_size_bytes | fmt_bytes }} -
- -
+ + +
+

Bereit

+
+

Kein Lauf aktiv

+
+
✎
@@ -79,4 +167,179 @@

Noch keine Backups eingerichtet. Lege jetzt den ersten Job an.

{% endif %} + + {% endblock %} diff --git a/app/web_server.py b/app/web_server.py index ea4fa5e..64f7705 100644 --- a/app/web_server.py +++ b/app/web_server.py @@ -1,15 +1,29 @@ import os +import posixpath import secrets +import shutil +import tarfile +import tempfile import threading -import time +from datetime import datetime from functools import wraps +from pathlib import Path +import zipfile from flask import Flask, flash, jsonify, redirect, render_template, request, session, url_for +import smbclient + +try: + import py7zr +except Exception: # noqa: BLE001 + py7zr = None from app.config_model import AppConfig, BackupExecutionJob, BackupJob from app.config_store import ConfigStore from app.remote_targets import ( TargetConnection, + browse_ftp_directories, + browse_sftp_directories, browse_smb_directories, create_smb_directory, list_smb_shares, @@ -20,6 +34,7 @@ from app.security import hash_password, verify_password COMPRESSION_METHODS = [ + "none", "zip", "tar.gz", "tar.bz2", @@ -29,15 +44,7 @@ COMPRESSION_METHODS = [ TARGET_PROTOCOLS = ["SMB", "LOCAL", "FTP", "SFTP"] ENCRYPTION_MODES = ["none", "password-aes256"] -SCHEDULE_MODES = [ - "daily", - "weekly", - "monthly", - "yearly", - "every_n_days", - "every_n_weeks", - "custom", -] +BACKUP_MODES = ["full", "sync", "clone"] class FlaskServer: @@ -70,79 +77,1116 @@ class FlaskServer: state.update(values) self._active_runs[backup_id] = state + def _build_runtime_meta(self, backup_id: int) -> dict: + meta = self.store.get_backup_runtime_meta(backup_id) + if meta is None: + return { + "last_run_at": None, + "last_size_bytes": None, + } + return meta + + def _start_backup_run(self, backup_id: int) -> tuple[bool, str]: + with self._run_lock: + existing = self._active_runs.get(backup_id) + if existing and bool(existing.get("is_running", False)): + return False, "Backup-Lauf ist bereits aktiv." + + job = self.store.get_backup_execution_job(backup_id) + if job is None: + return False, "Backup nicht gefunden." + + stop_event = threading.Event() + thread = threading.Thread( + target=self._run_backup_job, + args=(job, stop_event), + daemon=True, + name=f"backup-run-{backup_id}", + ) + + self._set_run_state( + backup_id, + is_running=True, + progress_percent=2, + progress_text="Startet...", + status_message="Manueller Lauf wird gestartet...", + stop_event=stop_event, + thread=thread, + ) + thread.start() + return True, f"Manueller Lauf für '{job.name}' wurde gestartet." + + def _stop_backup_run(self, backup_id: int) -> tuple[bool, str]: + with self._run_lock: + state = self._active_runs.get(backup_id) + if not state or not bool(state.get("is_running", False)): + return False, "Kein aktiver Lauf zum Abbrechen." + + stop_event = state.get("stop_event") + if stop_event is None or not hasattr(stop_event, "set"): + return False, "Abbruch konnte nicht angefordert werden." + + stop_event.set() + state["progress_text"] = "Stoppt..." + state["status_message"] = "Abbruch wurde angefordert..." + self._active_runs[backup_id] = state + + return True, "Abbruch wurde angefordert." + + @staticmethod + def _replace_target_tokens(pattern: str) -> str: + now = datetime.now() + value = pattern + value = value.replace("{date}", now.strftime("%Y-%m-%d")) + value = value.replace("{date_de}", now.strftime("%d.%m.%Y")) + value = value.replace("{date_compact}", now.strftime("%Y%m%d")) + value = value.replace("{time}", now.strftime("%H-%M-%S")) + value = value.replace("{time_short}", now.strftime("%H%M")) + value = value.replace("{datetime}", now.strftime("%Y-%m-%d_%H-%M-%S")) + return value + + @staticmethod + def _join_subpath(base: str, child: str) -> str: + normalized_base = normalize_subpath(base).rstrip("/") + clean_child = child.strip("/") + if not clean_child: + return normalize_subpath(base) + if normalized_base in {"", "/"}: + return f"/{clean_child}" + return f"{normalized_base}/{clean_child}" + + @staticmethod + def _smb_unc_path(host: str, share: str, subpath: str) -> str: + clean_share = share.strip().strip("\\/") + clean_subpath = normalize_subpath(subpath).strip("/") + if clean_subpath: + return f"\\\\{host}\\{clean_share}\\{clean_subpath.replace('/', '\\\\')}" + return f"\\\\{host}\\{clean_share}" + + def _ensure_smb_dir(self, host: str, share: str, subpath: str) -> None: + parts = [p for p in normalize_subpath(subpath).strip("/").split("/") if p] + current = "/" + for part in parts: + current = self._join_subpath(current, part) + unc = self._smb_unc_path(host, share, current) + try: + smbclient.mkdir(unc) + except Exception: # noqa: BLE001 + # Zielordner kann bereits existieren. + pass + + def _collect_source_files(self, job: BackupExecutionJob) -> list[tuple[str, str]]: + source_protocol = job.source_protocol.upper() + files: list[tuple[str, str]] = [] + + if source_protocol == "LOCAL": + source_path = Path(job.source_subpath).expanduser() + # Auto-detect file vs directory regardless of source_entry_type + if source_path.is_file(): + files.append((source_path.name, str(source_path))) + return files + if not source_path.is_dir(): + raise RuntimeError(f"Source-Pfad nicht gefunden: {source_path}") + + for entry in source_path.rglob("*"): + if entry.is_file(): + rel = entry.relative_to(source_path).as_posix() + files.append((rel, str(entry))) + return files + + if source_protocol == "SMB": + smbclient.register_session( + server=job.source_host, + username=job.source_username, + password=job.source_password, + port=job.source_port or 445, + ) + + source_subpath = normalize_subpath(job.source_subpath) + if job.source_entry_type == "file": + basename = posixpath.basename(source_subpath.rstrip("/")) + if not basename: + raise RuntimeError("SMB Source-Datei ist ungültig.") + files.append((basename, source_subpath)) + return files + + def walk(current_subpath: str, prefix: str = "") -> None: + unc = self._smb_unc_path(job.source_host, job.source_share, current_subpath) + for item in smbclient.scandir(unc): + if item.name in {".", ".."}: + continue + child_subpath = self._join_subpath(current_subpath, item.name) + child_rel = f"{prefix}/{item.name}" if prefix else item.name + if item.is_dir(): + walk(child_subpath, child_rel) + else: + files.append((child_rel, child_subpath)) + + walk(source_subpath) + return files + + if source_protocol in {"FTP", "SFTP"}: + return self._collect_source_files_ftp(job) + + def _collect_source_files_ftp(self, job: BackupExecutionJob) -> list[tuple[str, str]]: + """Returns list of (relative_path, ftp_path) pairs from an FTP source.""" + from ftplib import FTP as _FTP + ftp = _FTP() + files: list[tuple[str, str]] = [] + try: + ftp.connect(job.source_host, job.source_port or 21, timeout=30) + ftp.login(job.source_username, job.source_password) + subpath = normalize_subpath(job.source_subpath) + if job.source_entry_type == "file": + basename = posixpath.basename(subpath.rstrip("/")) or posixpath.basename(subpath) + if not basename: + raise RuntimeError("FTP Source-Datei ist ungültig.") + files.append((basename, subpath)) + return files + + def ftp_walk(path: str, prefix: str = "") -> None: + raw: list[str] = [] + try: + ftp.retrlines(f"LIST {path}", raw.append) + except Exception: # noqa: BLE001 + return + for line in raw: + parts = line.split(None, 8) + if len(parts) < 9: + continue + name = parts[8].strip() + if name in (".", ".."): + continue + is_dir = parts[0].startswith("d") + child_path = f"{path.rstrip('/')}/{name}" + child_rel = f"{prefix}/{name}" if prefix else name + if is_dir: + ftp_walk(child_path, child_rel) + else: + files.append((child_rel, child_path)) + + ftp_walk(subpath) + finally: + try: + ftp.quit() + except Exception: # noqa: BLE001 + pass + return files + + def _build_destination_spec(self, job: BackupExecutionJob, file_path: str, destination_protocol: str) -> str: + if destination_protocol == "LOCAL": + return file_path + if destination_protocol in {"FTP", "SFTP"}: + host_port = f"{job.destination_host}:{job.destination_port}" if job.destination_port else job.destination_host + return f"{host_port}|{job.destination_username}|{job.destination_password}|{file_path}" + return "|".join([job.destination_host, job.destination_share, file_path]) + + def _build_source_spec(self, job: BackupExecutionJob, file_path: str, source_protocol: str) -> str: + if source_protocol == "LOCAL": + return file_path + if source_protocol in {"FTP", "SFTP"}: + host_port = f"{job.source_host}:{job.source_port}" if job.source_port else job.source_host + return f"{host_port}|{job.source_username}|{job.source_password}|{file_path}" + # SMB + return "|".join([job.source_host, job.source_share, file_path]) + + def _copy_file_stream( + self, + source_protocol: str, + source_location: str, + destination_protocol: str, + destination_location: str, + stop_event: threading.Event, + ) -> int: + total_bytes = 0 + + if source_protocol == "LOCAL": + source_handle = open(source_location, "rb") + elif source_protocol in {"FTP", "SFTP"}: + # FTP/SFTP: download file to an in-memory buffer first + import io + from ftplib import FTP as _FTP + buf = io.BytesIO() + ftp_dl = _FTP() + # source_location is the FTP path; connection info comes from the job context + # We store it as "host:port|username|password|path" for FTP sources + parts = source_location.split("|", 3) + if len(parts) == 4: + ftp_host_port, ftp_user, ftp_pass, ftp_path = parts + _h, _p = (ftp_host_port.rsplit(":", 1) + [None])[:2] + ftp_dl.connect(_h, int(_p) if _p else 21, timeout=60) + ftp_dl.login(ftp_user, ftp_pass) + ftp_dl.retrbinary(f"RETR {ftp_path}", buf.write) + ftp_dl.quit() + buf.seek(0) + source_handle = buf + else: + raise RuntimeError(f"Ungültiger FTP-Source-Pfad: {source_location}") + else: + source_handle = smbclient.open_file( + self._smb_unc_path(*source_location.split("|", 2)), + mode="rb", + ) + + if destination_protocol == "LOCAL": + destination_handle = open(destination_location, "wb") + elif destination_protocol in {"FTP", "SFTP"}: + import io as _io + from ftplib import FTP as _FTP + parts = destination_location.split("|", 3) + if len(parts) != 4: + raise RuntimeError(f"Ungültiger FTP-Ziel-Pfad: {destination_location}") + ftp_host_port, ftp_user, ftp_pass, ftp_path = parts + _h, _p = (ftp_host_port.rsplit(":", 1) + [None])[:2] + ftp_dest = _FTP() + try: + ftp_dest.connect(_h, int(_p) if _p else 21, timeout=60) + ftp_dest.login(ftp_user, ftp_pass) + # Ensure parent directories exist + parent_dirs = [d for d in (ftp_path.rsplit("/", 1)[0] if "/" in ftp_path else "").split("/") if d] + cur = "" + for _d in parent_dirs: + cur = f"{cur}/{_d}" + try: ftp_dest.mkd(cur) + except Exception: pass + src_buf = _io.BytesIO(source_handle.read()) + source_handle.close() + ftp_dest.storbinary(f"STOR {ftp_path}", src_buf) + return len(src_buf.getvalue()) + except Exception as _exc: + raise RuntimeError(f"FTP-Upload fehlgeschlagen: {_exc}") from _exc + finally: + try: ftp_dest.quit() + except Exception: pass + else: + destination_handle = smbclient.open_file( + self._smb_unc_path(*destination_location.split("|", 2)), + mode="wb", + ) + + try: + while True: + if stop_event.is_set(): + raise RuntimeError("Lauf wurde abgebrochen.") + chunk = source_handle.read(1024 * 1024) + if not chunk: + break + destination_handle.write(chunk) + total_bytes += len(chunk) + finally: + source_handle.close() + destination_handle.close() + + return total_bytes + + @staticmethod + def _compression_extension(compression_method: str) -> str: + mapping = { + "zip": ".zip", + "tar.gz": ".tar.gz", + "tar.bz2": ".tar.bz2", + "tar.xz": ".tar.xz", + "7z": ".7z", + } + return mapping.get(compression_method, "") + + @staticmethod + def _ensure_archive_suffix(filename: str, compression_method: str) -> str: + ext = FlaskServer._compression_extension(compression_method) + if not ext: + return filename + lower_name = filename.lower() + if lower_name.endswith(ext): + return filename + return f"{filename}{ext}" + + def _build_archive_file( + self, + job: BackupExecutionJob, + source_files: list[tuple[str, str]], + source_protocol: str, + stop_event: threading.Event, + ) -> tuple[Path, str, int]: + method = job.compression_method + if method not in {"zip", "tar.gz", "tar.bz2", "tar.xz", "7z"}: + raise RuntimeError(f"Kompressionsmethode '{method}' wird nicht unterstützt.") + + if method == "7z" and py7zr is None: + raise RuntimeError( + "7z-Unterstützung ist nicht verfügbar. Bitte py7zr installieren und App neu starten." + ) + + target_base = self._replace_target_tokens(job.target_pattern).strip() + if not target_base: + target_base = f"backup_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}" + archive_name = self._ensure_archive_suffix(target_base, method) + + work_dir = Path(tempfile.mkdtemp(prefix="stfv-backup-")) + staging_dir = work_dir / "staging" + staging_dir.mkdir(parents=True, exist_ok=True) + + total_count = len(source_files) + for index, (relative_path, source_path) in enumerate(source_files, start=1): + if stop_event.is_set(): + raise RuntimeError("Lauf wurde abgebrochen.") + + local_target = staging_dir / relative_path + local_target.parent.mkdir(parents=True, exist_ok=True) + + source_spec = self._build_source_spec(job, source_path, source_protocol) + self._copy_file_stream( + source_protocol=source_protocol, + source_location=source_spec, + destination_protocol="LOCAL", + destination_location=str(local_target), + stop_event=stop_event, + ) + + progress = 52 + int((index / max(1, total_count)) * 18) + self._set_run_state( + job.backup_id, + is_running=True, + progress_percent=min(progress, 70), + progress_text="Sammle Dateien...", + status_message=f"Datei {index}/{total_count} für Archiv vorbereitet.", + ) + + archive_path = work_dir / archive_name + + self._set_run_state( + job.backup_id, + is_running=True, + progress_percent=74, + progress_text="Komprimiere...", + status_message=f"Erzeuge {method}-Archiv...", + ) + + if method == "zip": + with zipfile.ZipFile(archive_path, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: + for file_path in staging_dir.rglob("*"): + if file_path.is_file(): + arcname = file_path.relative_to(staging_dir).as_posix() + zf.write(file_path, arcname=arcname) + elif method in {"tar.gz", "tar.bz2", "tar.xz"}: + mode_map = { + "tar.gz": "w:gz", + "tar.bz2": "w:bz2", + "tar.xz": "w:xz", + } + with tarfile.open(archive_path, mode=mode_map[method]) as tf: + for file_path in staging_dir.rglob("*"): + if file_path.is_file(): + arcname = file_path.relative_to(staging_dir).as_posix() + tf.add(file_path, arcname=arcname) + else: + assert py7zr is not None + with py7zr.SevenZipFile(archive_path, mode="w") as sz: + for file_path in staging_dir.rglob("*"): + if file_path.is_file(): + arcname = file_path.relative_to(staging_dir).as_posix() + sz.write(file_path, arcname=arcname) + + archive_bytes = archive_path.stat().st_size + return archive_path, archive_name, archive_bytes + + def _list_destination_top_entries(self, job: BackupExecutionJob) -> list[tuple[str, float]]: + """Returns [(name, mtime)] for direct children of destination_subpath.""" + protocol = job.destination_protocol.upper() + subpath = normalize_subpath(job.destination_subpath) + + if protocol == "LOCAL": + result: list[tuple[str, float]] = [] + root = Path(job.destination_subpath).expanduser() + if not root.exists(): + return result + for entry in root.iterdir(): + try: + result.append((entry.name, entry.stat().st_mtime)) + except Exception: + pass + return result + + if protocol == "SMB": + result = [] + unc = self._smb_unc_path(job.destination_host, job.destination_share, subpath) + try: + for item in smbclient.scandir(unc): + if item.name in {".", ".."}: + continue + try: + result.append((item.name, float(item.stat().st_mtime or 0))) + except Exception: + result.append((item.name, 0.0)) + except Exception: + pass + return result + + if protocol == "FTP": + from ftplib import FTP as _FTP + from datetime import datetime as _dt + result = [] + ftp = _FTP() + try: + ftp.connect(job.destination_host, job.destination_port or 21, timeout=30) + ftp.login(job.destination_username, job.destination_password) + try: + for name, facts in ftp.mlsd(subpath): + if name in (".", ".."): + continue + mtime = 0.0 + mts = facts.get("modify", "") + if mts: + try: + mtime = _dt.strptime(mts[:14], "%Y%m%d%H%M%S").timestamp() + except Exception: + pass + result.append((name, mtime)) + except Exception: + raw: list[str] = [] + ftp.retrlines(f"LIST {subpath}", raw.append) + for line in raw: + parts = line.split(None, 8) + if len(parts) >= 9 and parts[8].strip() not in (".", ".."): + result.append((parts[8].strip(), 0.0)) + ftp.quit() + except Exception: + pass + return result + + if protocol == "SFTP": + import paramiko + result = [] + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + client.connect( + job.destination_host, port=job.destination_port or 22, + username=job.destination_username, password=job.destination_password, + timeout=30, + ) + sftp = client.open_sftp() + for attr in sftp.listdir_attr(subpath): + if attr.filename not in (".", ".."): + result.append((attr.filename, float(attr.st_mtime or 0))) + sftp.close() + except Exception: + pass + finally: + client.close() + return result + + return [] + + def _smb_rmdir_recursive(self, host: str, share: str, subpath: str) -> None: + unc = self._smb_unc_path(host, share, subpath) + try: + for item in smbclient.scandir(unc): + if item.name in {".", ".."}: + continue + child = self._join_subpath(subpath, item.name) + if item.is_dir(): + self._smb_rmdir_recursive(host, share, child) + else: + try: + smbclient.remove(self._smb_unc_path(host, share, child)) + except Exception: + pass + smbclient.rmdir(unc) + except Exception: + pass + + @staticmethod + def _ftp_delete_recursive(ftp, path: str) -> None: + try: + ftp.delete(path) + return + except Exception: + pass + raw: list[str] = [] + try: + ftp.retrlines(f"LIST {path}", raw.append) + except Exception: + return + for line in raw: + parts = line.split(None, 8) + if len(parts) < 9: + continue + name = parts[8].strip() + if name in (".", ".."): + continue + child = f"{path.rstrip('/')}/{name}" + if parts[0].startswith("d"): + FlaskServer._ftp_delete_recursive(ftp, child) + else: + try: + ftp.delete(child) + except Exception: + pass + try: + ftp.rmd(path) + except Exception: + pass + + @staticmethod + def _sftp_delete_recursive(sftp, path: str) -> None: + import stat as _stat + try: + attrs = sftp.listdir_attr(path) + except Exception: + try: + sftp.remove(path) + except Exception: + pass + return + for attr in attrs: + child = f"{path.rstrip('/')}/{attr.filename}" + if _stat.S_ISDIR(attr.st_mode or 0): + FlaskServer._sftp_delete_recursive(sftp, child) + else: + try: + sftp.remove(child) + except Exception: + pass + try: + sftp.rmdir(path) + except Exception: + pass + + def _delete_destination_entry(self, job: BackupExecutionJob, entry_name: str) -> None: + protocol = job.destination_protocol.upper() + child_subpath = normalize_subpath(self._join_subpath(job.destination_subpath, entry_name)) + try: + if protocol == "LOCAL": + target = Path(job.destination_subpath).expanduser() / entry_name + if target.is_dir(): + shutil.rmtree(target, ignore_errors=True) + else: + target.unlink(missing_ok=True) + elif protocol == "SMB": + unc = self._smb_unc_path(job.destination_host, job.destination_share, child_subpath) + try: + smbclient.remove(unc) + except Exception: + self._smb_rmdir_recursive(job.destination_host, job.destination_share, child_subpath) + elif protocol == "FTP": + from ftplib import FTP as _FTP + ftp = _FTP() + try: + ftp.connect(job.destination_host, job.destination_port or 21, timeout=30) + ftp.login(job.destination_username, job.destination_password) + self._ftp_delete_recursive(ftp, child_subpath) + ftp.quit() + except Exception: + pass + elif protocol == "SFTP": + import paramiko + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + client.connect( + job.destination_host, port=job.destination_port or 22, + username=job.destination_username, password=job.destination_password, + timeout=30, + ) + sftp = client.open_sftp() + self._sftp_delete_recursive(sftp, child_subpath) + sftp.close() + except Exception: + pass + finally: + client.close() + except Exception: + pass + + def _apply_rotation(self, job: BackupExecutionJob) -> None: + """Delete oldest destination entries if count exceeds rotation_keep.""" + if job.rotation_keep <= 0 or job.backup_mode in {"sync", "clone"}: + return + entries = self._list_destination_top_entries(job) + if len(entries) <= job.rotation_keep: + return + entries.sort(key=lambda x: x[1]) # oldest first + for name, _ in entries[:len(entries) - job.rotation_keep]: + self._delete_destination_entry(job, name) + + # ------------------------------------------------------------------ # + # Sync / Clone helpers # + # ------------------------------------------------------------------ # + + def _build_file_tree_local(self, subpath: str) -> dict[str, tuple[int, float]]: + result: dict[str, tuple[int, float]] = {} + root = Path(subpath).expanduser() + if not root.is_dir(): + return result + for entry in root.rglob("*"): + if entry.is_file(): + st = entry.stat() + result[entry.relative_to(root).as_posix()] = (st.st_size, st.st_mtime) + return result + + def _build_file_tree_smb(self, host: str, share: str, subpath: str) -> dict[str, tuple[int, float]]: + result: dict[str, tuple[int, float]] = {} + + def walk(current: str, prefix: str) -> None: + unc = self._smb_unc_path(host, share, current) + try: + entries = list(smbclient.scandir(unc)) + except Exception: + return + for item in entries: + if item.name in {".", ".."}: + continue + child = self._join_subpath(current, item.name) + rel = f"{prefix}/{item.name}" if prefix else item.name + if item.is_dir(): + walk(child, rel) + else: + try: + st = item.stat() + result[rel] = (st.st_size, float(st.st_mtime or 0)) + except Exception: + result[rel] = (0, 0.0) + + walk(normalize_subpath(subpath), "") + return result + + def _build_file_tree_ftp( + self, host: str, port: int | None, username: str, password: str, subpath: str + ) -> dict[str, tuple[int, float]]: + from ftplib import FTP as _FTP + from datetime import datetime as _dt + result: dict[str, tuple[int, float]] = {} + ftp = _FTP() + try: + ftp.connect(host, port or 21, timeout=30) + ftp.login(username, password) + + def walk(path: str, prefix: str) -> None: + # Prefer MLSD (reliable size + mtime) over LIST + try: + entries = list(ftp.mlsd(path)) + for name, facts in entries: + if name in (".", ".."): + continue + child = f"{path.rstrip('/')}/{name}" + rel = f"{prefix}/{name}" if prefix else name + if facts.get("type") == "dir": + walk(child, rel) + else: + size = int(facts.get("size", 0)) + mtime = 0.0 + mts = facts.get("modify", "") + if mts: + try: + mtime = _dt.strptime(mts[:14], "%Y%m%d%H%M%S").timestamp() + except Exception: + pass + result[rel] = (size, mtime) + return + except Exception: + pass + # Fallback: LIST + raw: list[str] = [] + try: + ftp.retrlines(f"LIST {path}", raw.append) + except Exception: + return + for line in raw: + parts = line.split(None, 8) + if len(parts) < 9: + continue + name = parts[8].strip() + if name in (".", ".."): + continue + is_dir = parts[0].startswith("d") + child = f"{path.rstrip('/')}/{name}" + rel = f"{prefix}/{name}" if prefix else name + if is_dir: + walk(child, rel) + else: + size = int(parts[4]) if parts[4].isdigit() else 0 + result[rel] = (size, 0.0) + + walk(normalize_subpath(subpath), "") + finally: + try: + ftp.quit() + except Exception: + pass + return result + + def _build_file_tree_sftp( + self, host: str, port: int | None, username: str, password: str, subpath: str + ) -> dict[str, tuple[int, float]]: + import stat as _stat + import paramiko + result: dict[str, tuple[int, float]] = {} + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + client.connect(host, port=port or 22, username=username, password=password, timeout=30) + sftp = client.open_sftp() + + def walk(path: str, prefix: str) -> None: + try: + attrs = sftp.listdir_attr(path) + except Exception: + return + for attr in attrs: + if attr.filename in (".", ".."): + continue + child = f"{path.rstrip('/')}/{attr.filename}" + rel = f"{prefix}/{attr.filename}" if prefix else attr.filename + if _stat.S_ISDIR(attr.st_mode or 0): + walk(child, rel) + else: + result[rel] = (attr.st_size or 0, float(attr.st_mtime or 0)) + + walk(normalize_subpath(subpath), "") + sftp.close() + finally: + client.close() + return result + + def _build_file_tree(self, job: BackupExecutionJob, side: str) -> dict[str, tuple[int, float]]: + if side == "source": + protocol = job.source_protocol.upper() + host, port, share = job.source_host, job.source_port, job.source_share + subpath, username, password = job.source_subpath, job.source_username, job.source_password + else: + protocol = job.destination_protocol.upper() + host, port, share = job.destination_host, job.destination_port, job.destination_share + subpath, username, password = job.destination_subpath, job.destination_username, job.destination_password + + if protocol == "LOCAL": + return self._build_file_tree_local(subpath) + if protocol == "SMB": + return self._build_file_tree_smb(host, share, subpath) + if protocol == "FTP": + return self._build_file_tree_ftp(host, port, username, password, subpath) + if protocol == "SFTP": + return self._build_file_tree_sftp(host, port, username, password, subpath) + return {} + + def _delete_destination_file( + self, protocol: str, job: BackupExecutionJob, rel_path: str + ) -> None: + subpath = self._join_subpath(job.destination_subpath, rel_path) + try: + if protocol == "LOCAL": + Path(job.destination_subpath).expanduser().joinpath(rel_path).unlink(missing_ok=True) + elif protocol == "SMB": + unc = self._smb_unc_path(job.destination_host, job.destination_share, subpath) + smbclient.remove(unc) + elif protocol == "FTP": + from ftplib import FTP as _FTP + ftp = _FTP() + ftp.connect(job.destination_host, job.destination_port or 21, timeout=30) + ftp.login(job.destination_username, job.destination_password) + ftp.delete(subpath) + ftp.quit() + elif protocol == "SFTP": + import paramiko + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect( + job.destination_host, port=job.destination_port or 22, + username=job.destination_username, password=job.destination_password, + timeout=30, + ) + client.open_sftp().remove(subpath) + client.close() + except Exception: + pass + + def _sync_backup_payload(self, job: BackupExecutionJob, stop_event: threading.Event) -> tuple[int, int]: + src_proto = job.source_protocol.upper() + dst_proto = job.destination_protocol.upper() + + if src_proto == "SMB": + smbclient.register_session( + server=job.source_host, username=job.source_username, + password=job.source_password, port=job.source_port or 445, + ) + if dst_proto == "SMB": + smbclient.register_session( + server=job.destination_host, username=job.destination_username, + password=job.destination_password, port=job.destination_port or 445, + ) + + self._set_run_state( + job.backup_id, is_running=True, progress_percent=10, + progress_text="Scanne Quelle...", status_message="Dateibaum der Quelle wird gelesen...", + ) + source_tree = self._build_file_tree(job, "source") + + self._set_run_state( + job.backup_id, is_running=True, progress_percent=25, + progress_text="Scanne Ziel...", status_message="Dateibaum des Ziels wird gelesen...", + ) + dest_tree = self._build_file_tree(job, "destination") + + # Determine changed/new files + to_transfer = [ + rel for rel, (src_sz, src_mt) in source_tree.items() + if rel not in dest_tree + or dest_tree[rel][0] != src_sz + or abs(dest_tree[rel][1] - src_mt) > 2 + ] + + # Clone only: files on destination that no longer exist on source + to_delete = ( + [rel for rel in dest_tree if rel not in source_tree] + if job.backup_mode == "clone" else [] + ) + + total_ops = len(to_transfer) + len(to_delete) + if total_ops == 0: + return 0, 0 + + copied_files = 0 + total_bytes = 0 + + for index, rel_path in enumerate(to_transfer, start=1): + if stop_event.is_set(): + raise RuntimeError("Lauf wurde abgebrochen.") + + src_subpath = self._join_subpath(job.source_subpath, rel_path) + dst_subpath = self._join_subpath(job.destination_subpath, rel_path) + + if dst_proto == "LOCAL": + dst_abs = Path(job.destination_subpath).expanduser() / rel_path + dst_abs.parent.mkdir(parents=True, exist_ok=True) + dest_spec = str(dst_abs) + elif dst_proto == "SMB": + dst_parent = normalize_subpath(posixpath.dirname(dst_subpath)) + self._ensure_smb_dir(job.destination_host, job.destination_share, dst_parent) + dest_spec = self._build_destination_spec(job, dst_subpath, dst_proto) + else: + dest_spec = self._build_destination_spec(job, dst_subpath, dst_proto) + + src_spec = self._build_source_spec(job, src_subpath, src_proto) + + total_bytes += self._copy_file_stream( + source_protocol=src_proto, + source_location=src_spec, + destination_protocol=dst_proto, + destination_location=dest_spec, + stop_event=stop_event, + ) + copied_files += 1 + + progress = 30 + int((index / max(1, total_ops)) * 60) + self._set_run_state( + job.backup_id, is_running=True, + progress_percent=min(progress, 90), + progress_text="Synchronisiere...", + status_message=f"{index}/{len(to_transfer)} Dateien übertragen.", + ) + + for rel_path in to_delete: + if stop_event.is_set(): + raise RuntimeError("Lauf wurde abgebrochen.") + self._delete_destination_file(dst_proto, job, rel_path) + + return copied_files, total_bytes + + def _transfer_backup_payload(self, job: BackupExecutionJob, stop_event: threading.Event) -> tuple[int, int]: + source_protocol = job.source_protocol.upper() + destination_protocol = job.destination_protocol.upper() + if source_protocol not in {"LOCAL", "SMB", "FTP", "SFTP"}: + raise RuntimeError(f"Source-Protokoll '{source_protocol}' ist nicht unterstützt.") + if destination_protocol not in {"LOCAL", "SMB", "FTP", "SFTP"}: + raise RuntimeError(f"Destination-Protokoll '{destination_protocol}' ist nicht unterstützt.") + + if job.backup_mode in {"sync", "clone"}: + return self._sync_backup_payload(job, stop_event) + + if destination_protocol == "SMB": + smbclient.register_session( + server=job.destination_host, + username=job.destination_username, + password=job.destination_password, + port=job.destination_port or 445, + ) + + source_files = self._collect_source_files(job) + if not source_files: + raise RuntimeError("Keine Dateien in der Quelle gefunden.") + + if job.compression_method != "none": + archive_path, archive_name, archive_bytes = self._build_archive_file( + job=job, + source_files=source_files, + source_protocol=source_protocol, + stop_event=stop_event, + ) + + destination_base = normalize_subpath(job.destination_subpath) + # archive_name already encodes the target_pattern with resolved tokens + extension + archive_rel = archive_name + + try: + if destination_protocol == "LOCAL": + destination_abs = (Path(job.destination_subpath).expanduser() / archive_rel).resolve() + destination_abs.parent.mkdir(parents=True, exist_ok=True) + destination_spec = str(destination_abs) + elif destination_protocol in {"FTP", "SFTP"}: + destination_subpath = self._join_subpath(destination_base, archive_rel) + destination_spec = self._build_destination_spec(job, destination_subpath, destination_protocol) + else: + destination_subpath = self._join_subpath(destination_base, archive_rel) + destination_parent = normalize_subpath(posixpath.dirname(destination_subpath)) + self._ensure_smb_dir(job.destination_host, job.destination_share, destination_parent) + destination_spec = "|".join([job.destination_host, job.destination_share, destination_subpath]) + + self._set_run_state( + job.backup_id, + is_running=True, + progress_percent=90, + progress_text="Übertrage Archiv...", + status_message=f"Schreibe Archiv {archive_name} ins Ziel...", + ) + + transferred = self._copy_file_stream( + source_protocol="LOCAL", + source_location=str(archive_path), + destination_protocol=destination_protocol, + destination_location=destination_spec, + stop_event=stop_event, + ) + return 1, max(transferred, archive_bytes) + finally: + shutil.rmtree(archive_path.parent, ignore_errors=True) + + target_pattern = self._replace_target_tokens(job.target_pattern) + destination_base = normalize_subpath(job.destination_subpath) + + if job.target_kind == "file" and len(source_files) != 1: + raise RuntimeError("Target ist Datei, aber die Quelle enthält mehrere Dateien.") + + copied_files = 0 + total_bytes = 0 + total_count = len(source_files) + + for index, (relative_path, source_path) in enumerate(source_files, start=1): + if stop_event.is_set(): + raise RuntimeError("Lauf wurde abgebrochen.") + + if job.target_kind == "file": + destination_rel = target_pattern + else: + destination_rel = f"{target_pattern}/{relative_path}" if target_pattern else relative_path + + if destination_protocol == "LOCAL": + destination_abs = (Path(job.destination_subpath).expanduser() / destination_rel).resolve() + destination_abs.parent.mkdir(parents=True, exist_ok=True) + destination_spec = str(destination_abs) + elif destination_protocol in {"FTP", "SFTP"}: + destination_subpath = self._join_subpath(destination_base, destination_rel) + destination_spec = self._build_destination_spec(job, destination_subpath, destination_protocol) + else: + destination_subpath = self._join_subpath(destination_base, destination_rel) + destination_parent = normalize_subpath(posixpath.dirname(destination_subpath)) + self._ensure_smb_dir(job.destination_host, job.destination_share, destination_parent) + destination_spec = "|".join([job.destination_host, job.destination_share, destination_subpath]) + + source_spec = self._build_source_spec(job, source_path, source_protocol) + + total_bytes += self._copy_file_stream( + source_protocol=source_protocol, + source_location=source_spec, + destination_protocol=destination_protocol, + destination_location=destination_spec, + stop_event=stop_event, + ) + copied_files += 1 + + progress = 50 + int((index / max(1, total_count)) * 45) + self._set_run_state( + job.backup_id, + is_running=True, + progress_percent=min(progress, 95), + progress_text="Übertrage Daten...", + status_message=f"Datei {index}/{total_count} wurde übertragen.", + ) + + return copied_files, total_bytes + def _run_backup_job(self, job: BackupExecutionJob, stop_event: threading.Event) -> None: backup_id = job.backup_id try: - steps = [ - (8, "Initialisiere Lauf..."), - (22, "Prüfe Quelle..."), - (45, "Prüfe Ziel..."), - (70, "Bereite Übertragung vor..."), - (88, "Finalisiere..."), - ] - - for progress, text in steps: - if stop_event.is_set(): - self._set_run_state( - backup_id, - is_running=False, - progress_percent=0, - progress_text="Gestoppt", - status_message="Lauf wurde manuell gestoppt.", - ) - return + self._set_run_state( + backup_id, + is_running=True, + progress_percent=8, + progress_text="Initialisiere Lauf...", + status_message="Initialisiere Lauf...", + ) + source = TargetConnection( + protocol=job.source_protocol, + host=job.source_host, + port=job.source_port, + share=job.source_share, + subpath=job.source_subpath, + username=job.source_username, + password=job.source_password, + ) + self._set_run_state( + backup_id, + is_running=True, + progress_percent=22, + progress_text="Prüfe Quelle...", + status_message="Prüfe Quelle...", + ) + ok, msg = test_connection(source) + if not ok: self._set_run_state( backup_id, - is_running=True, - progress_percent=progress, - progress_text=text, - status_message=text, + is_running=False, + progress_percent=0, + progress_text="Fehler", + status_message=f"Quelle nicht erreichbar: {msg}", ) + return - if progress == 22: - source = TargetConnection( - protocol=job.source_protocol, - host=job.source_host, - port=job.source_port, - share=job.source_share, - subpath=job.source_subpath, - username=job.source_username, - password=job.source_password, - ) - ok, msg = test_connection(source) - if not ok: - self._set_run_state( - backup_id, - is_running=False, - progress_percent=0, - progress_text="Fehler", - status_message=f"Quelle nicht erreichbar: {msg}", - ) - return + destination = TargetConnection( + protocol=job.destination_protocol, + host=job.destination_host, + port=job.destination_port, + share=job.destination_share, + subpath=job.destination_subpath, + username=job.destination_username, + password=job.destination_password, + ) + self._set_run_state( + backup_id, + is_running=True, + progress_percent=45, + progress_text="Prüfe Ziel...", + status_message="Prüfe Ziel...", + ) + ok, msg = test_connection(destination) + if not ok: + self._set_run_state( + backup_id, + is_running=False, + progress_percent=0, + progress_text="Fehler", + status_message=f"Ziel nicht erreichbar: {msg}", + ) + return - if progress == 45: - destination = TargetConnection( - protocol=job.destination_protocol, - host=job.destination_host, - port=job.destination_port, - share=job.destination_share, - subpath=job.destination_subpath, - username=job.destination_username, - password=job.destination_password, - ) - ok, msg = test_connection(destination) - if not ok: - self._set_run_state( - backup_id, - is_running=False, - progress_percent=0, - progress_text="Fehler", - status_message=f"Ziel nicht erreichbar: {msg}", - ) - return + self._set_run_state( + backup_id, + is_running=True, + progress_percent=50, + progress_text="Übertrage Daten...", + status_message="Datenübertragung läuft...", + ) - time.sleep(0.7) + copied_files, copied_bytes = self._transfer_backup_payload(job, stop_event) if stop_event.is_set(): self._set_run_state( @@ -154,13 +1198,17 @@ class FlaskServer: ) return - self.store.mark_backup_run(backup_id) + self.store.mark_backup_run(backup_id, copied_bytes) + self._apply_rotation(job) self._set_run_state( backup_id, is_running=False, progress_percent=100, progress_text="Fertig", - status_message="Backup-Lauf erfolgreich abgeschlossen.", + status_message=( + f"Backup erfolgreich abgeschlossen: {copied_files} Dateien, " + f"{copied_bytes} Bytes übertragen." + ), ) except Exception as exc: # noqa: BLE001 self._set_run_state( @@ -320,36 +1368,8 @@ class FlaskServer: return str(value) def parse_schedule_fields(form_data): - schedule_mode = form_data.get("schedule_mode", "daily").strip().lower() - schedule_time = form_data.get("schedule_time", "02:00").strip() - schedule_weekday = form_data.get("schedule_weekday", "1").strip() - schedule_day_of_month = form_data.get("schedule_day_of_month", "1").strip() - schedule_interval_raw = form_data.get("schedule_interval", "").strip() schedule_cron = form_data.get("schedule_cron", "").strip() - - if schedule_mode not in SCHEDULE_MODES: - return None, "Ungültiger Zeitplan-Modus." - - schedule_interval = None - if schedule_mode in {"every_n_days", "every_n_weeks"}: - try: - schedule_interval = int(schedule_interval_raw) - except ValueError: - schedule_interval = None - if schedule_interval is None or schedule_interval < 1: - return None, "Intervall muss eine ganze Zahl >= 1 sein." - - if schedule_mode == "custom" and not schedule_cron: - return None, "Bei benutzerdefiniertem Zeitplan ist ein CRON-Ausdruck erforderlich." - - return { - "schedule_mode": schedule_mode, - "schedule_time": schedule_time, - "schedule_weekday": schedule_weekday, - "schedule_day_of_month": schedule_day_of_month, - "schedule_interval": schedule_interval, - "schedule_cron": schedule_cron, - }, None + return {"schedule_mode": "custom", "schedule_cron": schedule_cron}, None def login_required(view_func): @wraps(view_func) @@ -406,79 +1426,161 @@ class FlaskServer: @login_required @admin_required def backup_run(backup_id: int): - backup = self.store.get_backup_edit_data(backup_id) - if backup is None: - flash("Backup nicht gefunden.", "error") - return redirect(url_for("dashboard")) - - ok = self.store.mark_backup_run(backup_id) - if not ok: - flash("Backup nicht gefunden.", "error") - return redirect(url_for("dashboard")) - - flash( - f"Manueller Lauf für '{backup['name']}' wurde gestartet und protokolliert.", - "success", - ) + ok, message = self._start_backup_run(backup_id) + flash(message, "success" if ok else "error") return redirect(url_for("dashboard")) + @app.post("/api/backups//run") + @login_required + @admin_required + def backup_run_api(backup_id: int): + ok, message = self._start_backup_run(backup_id) + status_code = 200 + if not ok: + status_code = 404 if "nicht gefunden" in message.lower() else 409 + return jsonify( + { + "ok": ok, + "message": message, + "status": self._get_run_status(backup_id), + "runtime": self._build_runtime_meta(backup_id), + } + ), status_code + + @app.post("/api/backups//stop") + @login_required + @admin_required + def backup_stop_api(backup_id: int): + ok, message = self._stop_backup_run(backup_id) + status_code = 200 if ok else 409 + return jsonify( + { + "ok": ok, + "message": message, + "status": self._get_run_status(backup_id), + "runtime": self._build_runtime_meta(backup_id), + } + ), status_code + + @app.get("/api/backups//status") + @login_required + @admin_required + def backup_status_api(backup_id: int): + return jsonify( + { + "ok": True, + "status": self._get_run_status(backup_id), + "runtime": self._build_runtime_meta(backup_id), + } + ) + @app.route("/backups//edit", methods=["GET", "POST"]) @login_required @admin_required def backup_edit(backup_id: int): - backup = self.store.get_backup_edit_data(backup_id) + backup = self.store.get_full_backup_for_edit(backup_id) if backup is None: flash("Backup nicht gefunden.", "error") return redirect(url_for("dashboard")) if request.method == "POST": - name = request.form.get("name", "").strip() - target_pattern = request.form.get("target_pattern", "").strip() - compression_method = request.form.get("compression_method", "zip") - encryption_mode = request.form.get("encryption_mode", "none") - archive_password = request.form.get("archive_password", "") - archive_password_confirm = request.form.get("archive_password_confirm", "") + payload = request.form.to_dict(flat=True) + name = payload.get("name", "").strip() + target_pattern = payload.get("target_pattern", "").strip() + compression_method = payload.get("compression_method", "zip") + encryption_mode = payload.get("encryption_mode", "none") + source_entry_type = payload.get("source_entry_type", "directory").strip().lower() + target_kind = payload.get("target_kind", "folder").strip().lower() + + # Source connection + source_protocol = payload.get("source_protocol", "").strip().upper() + if source_protocol == "LOCAL": + source_host, source_port = "local", None + else: + source_host, source_port, h_err = self._split_host_and_port(payload.get("source_host", "")) + if h_err: + flash(f"Source: {h_err}", "error") + return redirect(url_for("backup_edit", backup_id=backup_id)) + source_share = payload.get("source_share", "").strip() + source_subpath = normalize_subpath(payload.get("source_subpath", "/")) + source_username = payload.get("source_username", "").strip() + src_pwd_raw = payload.get("source_password", "") + source_password: str | None = None if src_pwd_raw == "***" else src_pwd_raw + + # Destination connection + destination_protocol = payload.get("destination_protocol", "").strip().upper() + if destination_protocol == "LOCAL": + destination_host, destination_port = "local", None + else: + destination_host, destination_port, d_err = self._split_host_and_port(payload.get("destination_host", "")) + if d_err: + flash(f"Destination: {d_err}", "error") + return redirect(url_for("backup_edit", backup_id=backup_id)) + destination_share = payload.get("destination_share", "").strip() + destination_subpath = normalize_subpath(payload.get("destination_subpath", "/")) + destination_username = payload.get("destination_username", "").strip() + dst_pwd_raw = payload.get("destination_password", "") + destination_password: str | None = None if dst_pwd_raw == "***" else dst_pwd_raw + + # Archive password + arc_pwd_raw = payload.get("archive_password", "") + arc_pwd_confirm = payload.get("archive_password_confirm", "") + archive_password: str | None = None if arc_pwd_raw == "***" else arc_pwd_raw + + schedule, _ = parse_schedule_fields(request.form) + backup_mode = payload.get("backup_mode", "full").strip().lower() + try: + rotation_keep = max(0, int(payload.get("rotation_keep", "0") or "0")) + except ValueError: + rotation_keep = 0 if not name or not target_pattern: flash("Bitte Name und Ziel ausfüllen.", "error") return redirect(url_for("backup_edit", backup_id=backup_id)) - if compression_method not in COMPRESSION_METHODS: flash("Ungültige Kompressionsmethode.", "error") return redirect(url_for("backup_edit", backup_id=backup_id)) - + if backup_mode not in BACKUP_MODES: + flash("Ungültiger Backup-Modus.", "error") + return redirect(url_for("backup_edit", backup_id=backup_id)) if encryption_mode not in ENCRYPTION_MODES: flash("Ungültiger Verschlüsselungsmodus.", "error") return redirect(url_for("backup_edit", backup_id=backup_id)) - - if encryption_mode == "password-aes256": - if archive_password and archive_password != archive_password_confirm: - flash("Passwort und Bestätigung stimmen nicht überein.", "error") - return redirect(url_for("backup_edit", backup_id=backup_id)) - - schedule, schedule_error = parse_schedule_fields(request.form) - if schedule_error: - flash(schedule_error, "error") + if archive_password and archive_password != arc_pwd_confirm: + flash("Archiv-Passwort und Bestätigung stimmen nicht überein.", "error") return redirect(url_for("backup_edit", backup_id=backup_id)) - assert schedule is not None - updated = self.store.update_backup_edit_data( + updated = self.store.update_full_backup( backup_id=backup_id, name=name, + source_protocol=source_protocol, + source_host=source_host, + source_port=source_port, + source_share=source_share, + source_subpath=source_subpath, + source_username=source_username, + source_password=source_password, + destination_protocol=destination_protocol, + destination_host=destination_host, + destination_port=destination_port, + destination_share=destination_share, + destination_subpath=destination_subpath, + destination_username=destination_username, + destination_password=destination_password, + source_entry_type=source_entry_type, + target_kind=target_kind, target_pattern=target_pattern, compression_method=compression_method, encryption_mode=encryption_mode, archive_password=archive_password, schedule_mode=schedule["schedule_mode"], - schedule_time=schedule["schedule_time"], - schedule_weekday=schedule["schedule_weekday"], - schedule_day_of_month=schedule["schedule_day_of_month"], - schedule_interval=schedule["schedule_interval"], schedule_cron=schedule["schedule_cron"], + backup_mode=backup_mode, + rotation_keep=rotation_keep, ) if not updated: - flash("Backup konnte nicht gespeichert werden (Name eventuell doppelt).", "error") + flash("Backup konnte nicht gespeichert werden.", "error") return redirect(url_for("backup_edit", backup_id=backup_id)) flash("Backup wurde aktualisiert.", "success") @@ -488,9 +1590,10 @@ class FlaskServer: "backup_edit.html", adminuser=session.get("adminuser", "admin"), backup=backup, + target_protocols=TARGET_PROTOCOLS, compression_methods=COMPRESSION_METHODS, encryption_modes=ENCRYPTION_MODES, - schedule_modes=SCHEDULE_MODES, + backup_modes=BACKUP_MODES, active_menu="dashboard", ) @@ -505,6 +1608,109 @@ class FlaskServer: flash("Backup wurde gelöscht.", "success") return redirect(url_for("dashboard")) + @app.post("/api/local/mkdir") + @login_required + def api_local_mkdir(): + import os + payload = request.get_json(silent=True) or {} + parent_path = payload.get("path", "").strip() + folder_name = payload.get("folder_name", "").strip() + + if not parent_path or not folder_name: + return jsonify({"ok": False, "message": "Pfad und Ordnername erforderlich."}) + if "/" in folder_name or "\\" in folder_name or folder_name in (".", ".."): + return jsonify({"ok": False, "message": "Ung\u00fcltiger Ordnername."}) + + new_path = os.path.join(os.path.abspath(parent_path), folder_name) + try: + os.makedirs(new_path, exist_ok=False) + return jsonify({"ok": True, "path": new_path}) + except FileExistsError: + return jsonify({"ok": False, "message": "Ordner existiert bereits."}) + except PermissionError: + return jsonify({"ok": False, "message": "Kein Schreibrecht."}) + except OSError as e: + return jsonify({"ok": False, "message": str(e)}) + + @app.get("/api/local/browse") + @login_required + def api_local_browse(): + import os + raw_path = request.args.get("path", "/").strip() + path = os.path.abspath(raw_path) if raw_path else "/" + + if not os.path.exists(path): + return jsonify({"ok": False, "message": f"Pfad nicht gefunden: {path}"}) + + try: + entries = [] + parent = os.path.dirname(path) if path != "/" else "/" + if path != parent: + entries.append({"name": "..", "path": parent, "entry_type": "up"}) + + for item in sorted(os.scandir(path), key=lambda e: (not e.is_dir(), e.name.lower())): + try: + entries.append({ + "name": item.name, + "path": item.path, + "entry_type": "directory" if item.is_dir() else "file", + }) + except (PermissionError, OSError): + continue + + return jsonify({"ok": True, "path": path, "entries": entries}) + except PermissionError: + return jsonify({"ok": False, "message": "Kein Zugriff auf diesen Pfad."}) + except OSError as e: + return jsonify({"ok": False, "message": str(e)}) + + @app.post("/api/cron/preview") + @login_required + def api_cron_preview(): + try: + from croniter import croniter + except ImportError: + return jsonify({"ok": False, "message": "croniter nicht installiert."}), 500 + + payload = request.get_json(silent=True) or {} + expr = payload.get("cron", "").strip() + if not expr: + return jsonify({"ok": False, "message": "Kein Ausdruck."}) + + try: + it = croniter(expr, datetime.now()) + next_dt = it.get_next(datetime) + except Exception: + return jsonify({"ok": False, "message": "Ungültiger CRON-Ausdruck."}) + + DE_DAYS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] + DE_MONTHS = ["Januar", "Februar", "März", "April", "Mai", "Juni", + "Juli", "August", "September", "Oktober", "November", "Dezember"] + next_run = ( + f"{DE_DAYS[next_dt.weekday()]}, {next_dt.day:02d}. " + f"{DE_MONTHS[next_dt.month - 1]} {next_dt.year} " + f"um {next_dt.hour:02d}:{next_dt.minute:02d} Uhr" + ) + + parts = expr.split() + description = "Benutzerdefinierter Zeitplan" + if len(parts) == 5: + minute, hour, dom, month, dow = parts + DE_DOW = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"] + t = f"{int(hour):02d}:{int(minute):02d} Uhr" if hour.isdigit() and minute.isdigit() else "" + if all(p in ("*", "*/1") for p in parts): + description = "Jede Minute" + elif hour == "*" and dom == "*" and month == "*" and dow == "*" and minute.isdigit(): + description = f"Jede Stunde um :{int(minute):02d} Uhr" + elif dom == "*" and month == "*" and dow == "*" and t: + description = f"Täglich um {t}" + elif dom == "*" and month == "*" and dow.isdigit() and t: + description = f"Wöchentlich am {DE_DOW[int(dow) % 7]} um {t}" + elif month == "*" and dow == "*" and dom.isdigit() and t: + description = f"Monatlich am {dom}. um {t}" + + return jsonify({"ok": True, "description": description, "next_run": next_run}) + @app.post("/api/target/test") @login_required @admin_required @@ -523,6 +1729,36 @@ class FlaskServer: status = 200 if ok else 400 return jsonify({"ok": ok, "message": message}), status + @app.post("/api/target/browse") + @login_required + @admin_required + def api_target_browse(): + payload = request.get_json(silent=True) or {} + prefix = payload.get("prefix", "") + if prefix not in {"source", "destination"}: + return jsonify({"ok": False, "message": "Ungueltiger Bereich."}), 400 + + target, error = self._target_from_payload(payload, prefix) + if error: + return jsonify({"ok": False, "message": error}), 400 + assert target is not None + + protocol = target.protocol.upper() + if protocol == "SMB": + if not target.share: + return jsonify({"ok": False, "message": "Bitte zuerst eine SMB-Freigabe (Share) angeben."}), 400 + ok, path_or_err, entries = browse_smb_directories(target) + elif protocol == "FTP": + ok, path_or_err, entries = browse_ftp_directories(target) + elif protocol == "SFTP": + ok, path_or_err, entries = browse_sftp_directories(target) + else: + return jsonify({"ok": False, "message": f"Browser nicht verfügbar für: {protocol}"}), 400 + + if not ok: + return jsonify({"ok": False, "message": path_or_err}), 400 + return jsonify({"ok": True, "path": path_or_err, "entries": entries}) + @app.post("/api/target/browse-smb") @login_required @admin_required @@ -645,6 +1881,11 @@ class FlaskServer: encryption_mode = request.form.get("encryption_mode", "none") archive_password = request.form.get("archive_password", "") archive_password_confirm = request.form.get("archive_password_confirm", "") + backup_mode = request.form.get("backup_mode", "full").strip().lower() + try: + rotation_keep = max(0, int(request.form.get("rotation_keep", "0") or "0")) + except ValueError: + rotation_keep = 0 schedule, schedule_error = parse_schedule_fields(request.form) if schedule_error: @@ -667,6 +1908,9 @@ class FlaskServer: if compression_method not in COMPRESSION_METHODS: flash("Ungueltige Kompressionsmethode.", "error") return redirect(url_for("backup_new")) + if backup_mode not in BACKUP_MODES: + flash("Ungueltiger Backup-Modus.", "error") + return redirect(url_for("backup_new")) if source_entry_type not in {"directory", "file"}: flash("Ungueltiger Source-Typ (Datei/Ordner).", "error") @@ -715,11 +1959,9 @@ class FlaskServer: encryption_mode=encryption_mode, archive_password=archive_password, schedule_mode=schedule["schedule_mode"], - schedule_time=schedule["schedule_time"], - schedule_weekday=schedule["schedule_weekday"], - schedule_day_of_month=schedule["schedule_day_of_month"], - schedule_interval=schedule["schedule_interval"], schedule_cron=schedule["schedule_cron"], + backup_mode=backup_mode, + rotation_keep=rotation_keep, ) created = self.store.create_backup(backup) @@ -735,7 +1977,7 @@ class FlaskServer: adminuser=session.get("adminuser", "admin"), compression_methods=COMPRESSION_METHODS, encryption_modes=ENCRYPTION_MODES, - schedule_modes=SCHEDULE_MODES, + backup_modes=BACKUP_MODES, target_protocols=TARGET_PROTOCOLS, active_menu="backups", ) @@ -861,6 +2103,52 @@ class FlaskServer: return app + def _run_scheduler(self) -> None: + from datetime import timedelta + try: + from croniter import croniter as CronIter + except ImportError: + return + + fired: dict[int, str] = {} # backup_id → YYYYMMDDHHmm of the scheduled slot + while not self._scheduler_stop.is_set(): + now = datetime.now() + # Sleep until the next minute boundary + sleep_secs = 61 - now.second - now.microsecond / 1_000_000 + if self._scheduler_stop.wait(sleep_secs): + break + + fire_time = datetime.now() + try: + backups = self.store.list_backups() + except Exception: + continue + + for b in backups: + if not b.schedule_cron: + continue + try: + it = CronIter(b.schedule_cron, fire_time - timedelta(seconds=65)) + next_dt = it.get_next(datetime) + diff = (fire_time - next_dt).total_seconds() + if not (0 <= diff <= 65): + continue + # Key by scheduled slot to prevent double-firing across minute boundaries + sched_key = next_dt.strftime("%Y%m%d%H%M") + if fired.get(b.backup_id) == sched_key: + continue + fired[b.backup_id] = sched_key + self._start_backup_run(b.backup_id) + except Exception: + continue + def run(self) -> None: + import os app = self.create_app() + # Start scheduler only in the actual serving process (not Werkzeug reloader watcher) + if os.environ.get("WERKZEUG_RUN_MAIN") == "true" or not self.config.debug: + self._scheduler_stop = threading.Event() + threading.Thread( + target=self._run_scheduler, daemon=True, name="backup-scheduler" + ).start() app.run(host=self.config.ip, port=self.config.port, debug=self.config.debug) diff --git a/data/config.db b/data/config.db deleted file mode 100644 index 79b0d072f89375b63ff1a1ef3e6dae267335978a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28672 zcmeI4+i%)d7{E=^T$h$kRmGyHimOyhvxeX+#7sydlq4*W3xTZU#q#AC+<0sEKsr|fmpo_DxILJ*NAb*;XGjqT&_JLmK7I~V(4&iPqdcM++Xc13bYmUv3| ze8f*ANf5+yye{Ci7oOssN4*2Q^$qRcxBHyfX;vRUNf7gMRpQCf%Eu?)tuS-dmD&3g zARY(+0U+?z68OzOw-8uc^Zo9*l3Yfzq+EHH)7{J`vdMTZN#^2v=_J{WBwsI+V}h=d zspDMoAekjkv#CrxTOf~;1u~w`ourO&I+^5gZhNezAyp8WOTNpI$0vBrr_*HqICYj! zju&@K&sLCV*`{kMW_eU|qDskh+y{s|CCR>J*u>-=Pqrkt^ne^Z2ibS8eqtt;e3bl5dmy@q9W*Ci2-VuFzsGl}VoG;+fO&>Yk-er+%kl*d}}R+_c@kjpT^? zM$Yua`!%p<>SkzjdsJ_{NH+mwxOPKy8`iC=-@o)tRkxAinsx)H+bt4Tx^c_1198nP ztH{Q(xW?KIt6dW-rh0GkhG`%y?I7nO${4k+-~9SQP2%sNv@^xz;}Hk72CX3dj@H2ZUyJ(1MxKnh(5r<14hgudcX*svf3|ZXM2zbZ56tzHT+d+gNk! z!?!aFfk43b$=6P^)w($Cuh|j(cGQ2~8IEtuCU%JZW}+lHQ}sT&BT8yTH+rra^t-*X zLXEG^pRG06+M1mY!~*vfinc36y1i*V5-zrG*FG@5g}}>~zE7dH8zsvU71Pl4;$UxP z#G!-uhj}$|m`ogzukj5~jHI{5lhdsUEwVM-klkqsTlZQjlD%TjUE_0Td$dJhLPl+w zUU8Wq_xb$%7(34A-zV?_0SEvAAOHk_01yBIKmZ5;0U!VbfB+DfNZ=;oTPD_KmR{U6 zSeDvpf1dq{$2U3y0P>ZpF(MwJXZY|dhH8aZ5k;mnUKV(siZB}5)hJ2jSX$wbtZF>k zeMM0mqefUkp;(zgD56plgZS1k9HUVzBVb)k<9KCP5Ga}BP(%*z$`OvmQL?Or8HQrQ zK`Iht!X(2+`AC%J$3%6W|IY~j*~ANklRy9n00AHX1b_e#00KY&2mk>f00e-*FoBzG z-y(s>0hUz|k00;m9AOHk_01yBIKmZ5;0U!VbzHkD| zGqY<;-AOzaeY1h3_UsMq_x~UNLEr@f5C8%|00;m9AOHk_01yBIKmZ7Q5d=Q6zVC_k zXB+FG)tmgYjn$WUvw_zi7g?4d{QmXTH>&}^pV&A?byq2yo_e0yqp2O5-l3VDaM7wTWiJn@CTH{4mOxpo za)hVVJYmGz<>_ALfDf Y00e*l5C8%|00>M+U}biFsXwOjFS$4QmjD0& diff --git a/main.py b/main.py index cf35953..a33e8b3 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import sys from pathlib import Path from app.app_runner import AppRunner @@ -6,7 +7,11 @@ from app.app_runner import AppRunner def main() -> None: base_dir = Path(__file__).resolve().parent - AppRunner(base_dir=base_dir, entry_script=Path(__file__)).run() + runner = AppRunner(base_dir=base_dir, entry_script=Path(__file__)) + if "--setup" in sys.argv: + runner.setup() + else: + runner.run() if __name__ == "__main__": diff --git a/requirements.txt b/requirements.txt index 0c61df0..0c8925b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,5 @@ cryptography>=43.0,<44.0 pysmb>=1.2.10,<2.0 paramiko>=3.4,<4.0 smbprotocol>=1.13.0,<2.0 +py7zr>=0.22,<1.0 +croniter>=1.0,<2.0