v0.0.1 first commit
This commit is contained in:
@@ -1,2 +1,101 @@
|
|||||||
# Nextcloud_Backup
|
# StFV Backup
|
||||||
|
|
||||||
|
## Modulares Python-Startsystem
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Modulares Startsystem fuer die Anwendung."""
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.dependencies import DependencyManager
|
||||||
|
from app.paths import AppPaths
|
||||||
|
from app.ui import ConsoleUI
|
||||||
|
from app.venv_manager import VirtualEnvManager
|
||||||
|
|
||||||
|
|
||||||
|
class AppRunner:
|
||||||
|
def __init__(self, base_dir: Path, entry_script: Path) -> None:
|
||||||
|
self.paths = AppPaths(base_dir=base_dir)
|
||||||
|
self.entry_script = entry_script
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
ConsoleUI.headline("StFV Backup Bootstrap")
|
||||||
|
|
||||||
|
venv = VirtualEnvManager(self.paths.venv_dir, self.entry_script)
|
||||||
|
if not venv.in_virtualenv():
|
||||||
|
ConsoleUI.info("Virtuelle Umgebung wird vorbereitet...")
|
||||||
|
venv.ensure_and_reexec_if_needed()
|
||||||
|
|
||||||
|
deps = DependencyManager(self.paths.requirements_path)
|
||||||
|
missing = deps.install_missing()
|
||||||
|
if missing:
|
||||||
|
ConsoleUI.success(f"Abhaengigkeiten installiert: {', '.join(missing)}")
|
||||||
|
else:
|
||||||
|
ConsoleUI.info("Alle Abhaengigkeiten sind bereits installiert.")
|
||||||
|
|
||||||
|
from app.config_store import ConfigStore
|
||||||
|
from app.setup_wizard import SetupWizard
|
||||||
|
from app.web_server import FlaskServer
|
||||||
|
|
||||||
|
store = ConfigStore(self.paths.db_path)
|
||||||
|
store.ensure_schema()
|
||||||
|
|
||||||
|
config = store.load_config()
|
||||||
|
if config is None:
|
||||||
|
ConsoleUI.warn("Keine vollstaendige Konfiguration gefunden.")
|
||||||
|
setup_data = SetupWizard().collect_initial()
|
||||||
|
store.save_config(setup_data.config)
|
||||||
|
store.create_user(
|
||||||
|
setup_data.admin_username,
|
||||||
|
setup_data.admin_password_hash,
|
||||||
|
is_admin=True,
|
||||||
|
)
|
||||||
|
config = setup_data.config
|
||||||
|
ConsoleUI.success("Konfiguration und Admin-Benutzer wurden gespeichert.")
|
||||||
|
elif not store.has_any_user():
|
||||||
|
ConsoleUI.warn("Keine Benutzer gefunden.")
|
||||||
|
admin_username, admin_hash = SetupWizard().collect_admin_user()
|
||||||
|
store.create_user(admin_username, admin_hash, is_admin=True)
|
||||||
|
ConsoleUI.success("Admin-Benutzer wurde gespeichert.")
|
||||||
|
|
||||||
|
ConsoleUI.success("System ist bereit.")
|
||||||
|
ConsoleUI.info(
|
||||||
|
f"Flask startet auf http://{config.ip}:{config.port} (debug={config.debug})"
|
||||||
|
)
|
||||||
|
FlaskServer(config=config, store=store).run()
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AppConfig:
|
||||||
|
ip: str
|
||||||
|
port: int
|
||||||
|
debug: bool
|
||||||
|
|
||||||
|
def as_db_tuple(self) -> tuple[str, int, int]:
|
||||||
|
return (
|
||||||
|
self.ip,
|
||||||
|
self.port,
|
||||||
|
1 if self.debug else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UserAccount:
|
||||||
|
username: str
|
||||||
|
password_hash: str
|
||||||
|
is_admin: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class InitialSetupData:
|
||||||
|
config: AppConfig
|
||||||
|
admin_username: str
|
||||||
|
admin_password_hash: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BackupJob:
|
||||||
|
name: str
|
||||||
|
source_protocol: str
|
||||||
|
source_host: str
|
||||||
|
source_port: int | None
|
||||||
|
source_share: str
|
||||||
|
source_subpath: str
|
||||||
|
source_username: str
|
||||||
|
source_password: str
|
||||||
|
destination_protocol: str
|
||||||
|
destination_host: str
|
||||||
|
destination_port: int | None
|
||||||
|
destination_share: str
|
||||||
|
destination_subpath: str
|
||||||
|
destination_username: str
|
||||||
|
destination_password: str
|
||||||
|
source_entry_type: str
|
||||||
|
target_kind: str
|
||||||
|
target_pattern: str
|
||||||
|
compression_method: str
|
||||||
|
encryption_mode: str
|
||||||
|
archive_password: str
|
||||||
|
schedule_mode: str
|
||||||
|
schedule_time: str
|
||||||
|
schedule_weekday: str
|
||||||
|
schedule_day_of_month: str
|
||||||
|
schedule_interval: int | None
|
||||||
|
schedule_cron: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BackupExecutionJob:
|
||||||
|
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
|
||||||
|
destination_protocol: str
|
||||||
|
destination_host: str
|
||||||
|
destination_port: int | None
|
||||||
|
destination_share: str
|
||||||
|
destination_subpath: str
|
||||||
|
destination_username: str
|
||||||
|
destination_password: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BackupSummary:
|
||||||
|
backup_id: int
|
||||||
|
name: str
|
||||||
|
source_label: str
|
||||||
|
destination_label: str
|
||||||
|
target_pattern: str
|
||||||
|
compression_method: str
|
||||||
|
schedule_mode: str
|
||||||
|
last_run_at: str | None
|
||||||
|
last_size_bytes: int | None
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config_model import AppConfig, BackupExecutionJob, BackupJob, BackupSummary, UserAccount
|
||||||
|
from app.crypto import CryptoManager
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigStore:
|
||||||
|
def __init__(self, db_path: Path) -> None:
|
||||||
|
self.db_path = db_path
|
||||||
|
self.crypto = CryptoManager(db_path.parent / "secret.key")
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def ensure_schema(self) -> None:
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS app_config (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
ip TEXT,
|
||||||
|
port INTEGER,
|
||||||
|
debug INTEGER,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS backups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
source_protocol TEXT NOT NULL,
|
||||||
|
source_host TEXT NOT NULL,
|
||||||
|
source_port INTEGER,
|
||||||
|
source_share TEXT NOT NULL,
|
||||||
|
source_subpath TEXT NOT NULL,
|
||||||
|
source_username TEXT NOT NULL,
|
||||||
|
source_password_enc TEXT NOT NULL,
|
||||||
|
destination_protocol TEXT NOT NULL,
|
||||||
|
destination_host TEXT NOT NULL,
|
||||||
|
destination_port INTEGER,
|
||||||
|
destination_share TEXT NOT NULL,
|
||||||
|
destination_subpath TEXT NOT NULL,
|
||||||
|
destination_username TEXT NOT NULL,
|
||||||
|
destination_password_enc TEXT NOT NULL,
|
||||||
|
source_entry_type TEXT NOT NULL DEFAULT 'directory',
|
||||||
|
target_kind TEXT NOT NULL DEFAULT 'folder',
|
||||||
|
target_pattern TEXT NOT NULL,
|
||||||
|
compression_method TEXT NOT NULL,
|
||||||
|
encryption_mode TEXT NOT NULL DEFAULT 'none',
|
||||||
|
archive_password_enc TEXT NOT NULL,
|
||||||
|
schedule_mode TEXT NOT NULL DEFAULT 'daily',
|
||||||
|
schedule_time TEXT NOT NULL DEFAULT '02:00',
|
||||||
|
schedule_weekday TEXT NOT NULL DEFAULT '1',
|
||||||
|
schedule_day_of_month TEXT NOT NULL DEFAULT '1',
|
||||||
|
schedule_interval INTEGER,
|
||||||
|
schedule_cron TEXT NOT NULL DEFAULT '',
|
||||||
|
last_run_at TEXT,
|
||||||
|
last_size_bytes INTEGER,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
backup_columns = {
|
||||||
|
row["name"].lower()
|
||||||
|
for row in conn.execute("PRAGMA table_info(backups)").fetchall()
|
||||||
|
}
|
||||||
|
if "source_port" not in backup_columns:
|
||||||
|
conn.execute("ALTER TABLE backups ADD COLUMN source_port INTEGER")
|
||||||
|
if "destination_port" not in backup_columns:
|
||||||
|
conn.execute("ALTER TABLE backups ADD COLUMN destination_port INTEGER")
|
||||||
|
if "source_entry_type" not in backup_columns:
|
||||||
|
conn.execute("ALTER TABLE backups ADD COLUMN source_entry_type TEXT NOT NULL DEFAULT 'directory'")
|
||||||
|
if "target_kind" not in backup_columns:
|
||||||
|
conn.execute("ALTER TABLE backups ADD COLUMN target_kind TEXT NOT NULL DEFAULT 'folder'")
|
||||||
|
if "encryption_mode" not in backup_columns:
|
||||||
|
conn.execute("ALTER TABLE backups ADD COLUMN encryption_mode TEXT NOT NULL DEFAULT 'none'")
|
||||||
|
if "schedule_mode" not in backup_columns:
|
||||||
|
conn.execute("ALTER TABLE backups ADD COLUMN schedule_mode TEXT NOT NULL DEFAULT 'daily'")
|
||||||
|
if "schedule_time" not in backup_columns:
|
||||||
|
conn.execute("ALTER TABLE backups ADD COLUMN schedule_time TEXT NOT NULL DEFAULT '02:00'")
|
||||||
|
if "schedule_weekday" not in backup_columns:
|
||||||
|
conn.execute("ALTER TABLE backups ADD COLUMN schedule_weekday TEXT NOT NULL DEFAULT '1'")
|
||||||
|
if "schedule_day_of_month" not in backup_columns:
|
||||||
|
conn.execute("ALTER TABLE backups ADD COLUMN schedule_day_of_month TEXT NOT NULL DEFAULT '1'")
|
||||||
|
if "schedule_interval" not in backup_columns:
|
||||||
|
conn.execute("ALTER TABLE backups ADD COLUMN schedule_interval INTEGER")
|
||||||
|
if "schedule_cron" not in backup_columns:
|
||||||
|
conn.execute("ALTER TABLE backups ADD COLUMN schedule_cron TEXT NOT NULL DEFAULT ''")
|
||||||
|
|
||||||
|
columns = {
|
||||||
|
row["name"].lower()
|
||||||
|
for row in conn.execute("PRAGMA table_info(app_config)").fetchall()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Legacy-Felder koennen in bestehenden Datenbanken fehlen/enthalten.
|
||||||
|
# Falls vorhanden, werden sie fuer Migration gelesen, danach nicht mehr genutzt.
|
||||||
|
if "adminuser" not in columns:
|
||||||
|
conn.execute("ALTER TABLE app_config ADD COLUMN adminuser TEXT")
|
||||||
|
if "adminpassword" not in columns:
|
||||||
|
conn.execute("ALTER TABLE app_config ADD COLUMN adminpassword TEXT")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
self._migrate_legacy_admin_if_needed()
|
||||||
|
|
||||||
|
def _migrate_legacy_admin_if_needed(self) -> None:
|
||||||
|
with self._connect() as conn:
|
||||||
|
user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||||
|
if user_count > 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT adminuser, adminpassword FROM app_config WHERE id = 1"
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
legacy_user = row["adminuser"]
|
||||||
|
legacy_hash = row["adminpassword"]
|
||||||
|
if not isinstance(legacy_user, str) or not legacy_user.strip():
|
||||||
|
return
|
||||||
|
if not isinstance(legacy_hash, str) or not legacy_hash.strip():
|
||||||
|
return
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (username, password_hash, is_admin, updated_at)
|
||||||
|
VALUES (?, ?, 1, CURRENT_TIMESTAMP)
|
||||||
|
""",
|
||||||
|
(legacy_user.strip(), legacy_hash.strip()),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def load_config(self) -> AppConfig | None:
|
||||||
|
with self._connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT ip, port, debug "
|
||||||
|
"FROM app_config WHERE id = 1"
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ip = row["ip"]
|
||||||
|
port = row["port"]
|
||||||
|
debug = row["debug"]
|
||||||
|
|
||||||
|
if not isinstance(ip, str) or not ip.strip():
|
||||||
|
return None
|
||||||
|
if not isinstance(port, int):
|
||||||
|
return None
|
||||||
|
if debug not in (0, 1):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return AppConfig(
|
||||||
|
ip=ip.strip(),
|
||||||
|
port=port,
|
||||||
|
debug=bool(debug),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_config(self, config: AppConfig) -> None:
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO app_config (id, ip, port, debug, updated_at)
|
||||||
|
VALUES (1, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
ip = excluded.ip,
|
||||||
|
port = excluded.port,
|
||||||
|
debug = excluded.debug,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
config.as_db_tuple(),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def has_any_user(self) -> bool:
|
||||||
|
with self._connect() as conn:
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||||
|
return count > 0
|
||||||
|
|
||||||
|
def create_user(self, username: str, password_hash: str, is_admin: bool = False) -> bool:
|
||||||
|
name = username.strip()
|
||||||
|
if not name:
|
||||||
|
return False
|
||||||
|
|
||||||
|
with self._connect() as conn:
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (username, password_hash, is_admin, updated_at)
|
||||||
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
""",
|
||||||
|
(name, password_hash, 1 if is_admin else 0),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def list_users(self) -> list[UserAccount]:
|
||||||
|
with self._connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT username, password_hash, is_admin FROM users ORDER BY username ASC"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
UserAccount(
|
||||||
|
username=row["username"],
|
||||||
|
password_hash=row["password_hash"],
|
||||||
|
is_admin=bool(row["is_admin"]),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_user(self, username: str) -> UserAccount | None:
|
||||||
|
with self._connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT username, password_hash, is_admin FROM users WHERE username = ?",
|
||||||
|
(username.strip(),),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return UserAccount(
|
||||||
|
username=row["username"],
|
||||||
|
password_hash=row["password_hash"],
|
||||||
|
is_admin=bool(row["is_admin"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_user_credentials(
|
||||||
|
self,
|
||||||
|
current_username: str,
|
||||||
|
new_username: str,
|
||||||
|
new_password_hash: str,
|
||||||
|
) -> bool:
|
||||||
|
with self._connect() as conn:
|
||||||
|
try:
|
||||||
|
result = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET username = ?, password_hash = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE username = ?
|
||||||
|
""",
|
||||||
|
(new_username.strip(), new_password_hash, current_username.strip()),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return result.rowcount == 1
|
||||||
|
|
||||||
|
def create_backup(self, backup: BackupJob) -> bool:
|
||||||
|
with self._connect() as conn:
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO backups (
|
||||||
|
name,
|
||||||
|
source_protocol,
|
||||||
|
source_host,
|
||||||
|
source_port,
|
||||||
|
source_share,
|
||||||
|
source_subpath,
|
||||||
|
source_username,
|
||||||
|
source_password_enc,
|
||||||
|
destination_protocol,
|
||||||
|
destination_host,
|
||||||
|
destination_port,
|
||||||
|
destination_share,
|
||||||
|
destination_subpath,
|
||||||
|
destination_username,
|
||||||
|
destination_password_enc,
|
||||||
|
source_entry_type,
|
||||||
|
target_kind,
|
||||||
|
target_pattern,
|
||||||
|
compression_method,
|
||||||
|
encryption_mode,
|
||||||
|
archive_password_enc,
|
||||||
|
schedule_mode,
|
||||||
|
schedule_time,
|
||||||
|
schedule_weekday,
|
||||||
|
schedule_day_of_month,
|
||||||
|
schedule_interval,
|
||||||
|
schedule_cron,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
backup.name,
|
||||||
|
backup.source_protocol,
|
||||||
|
backup.source_host,
|
||||||
|
backup.source_port,
|
||||||
|
backup.source_share,
|
||||||
|
backup.source_subpath,
|
||||||
|
backup.source_username,
|
||||||
|
self.crypto.encrypt_text(backup.source_password),
|
||||||
|
backup.destination_protocol,
|
||||||
|
backup.destination_host,
|
||||||
|
backup.destination_port,
|
||||||
|
backup.destination_share,
|
||||||
|
backup.destination_subpath,
|
||||||
|
backup.destination_username,
|
||||||
|
self.crypto.encrypt_text(backup.destination_password),
|
||||||
|
backup.source_entry_type,
|
||||||
|
backup.target_kind,
|
||||||
|
backup.target_pattern,
|
||||||
|
backup.compression_method,
|
||||||
|
backup.encryption_mode,
|
||||||
|
self.crypto.encrypt_text(backup.archive_password),
|
||||||
|
backup.schedule_mode,
|
||||||
|
backup.schedule_time,
|
||||||
|
backup.schedule_weekday,
|
||||||
|
backup.schedule_day_of_month,
|
||||||
|
backup.schedule_interval,
|
||||||
|
backup.schedule_cron,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def list_backups(self) -> list[BackupSummary]:
|
||||||
|
with self._connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
source_protocol,
|
||||||
|
source_host,
|
||||||
|
source_port,
|
||||||
|
source_share,
|
||||||
|
source_subpath,
|
||||||
|
destination_protocol,
|
||||||
|
destination_host,
|
||||||
|
destination_port,
|
||||||
|
destination_share,
|
||||||
|
destination_subpath,
|
||||||
|
target_pattern,
|
||||||
|
compression_method,
|
||||||
|
schedule_mode,
|
||||||
|
last_run_at,
|
||||||
|
last_size_bytes
|
||||||
|
FROM backups
|
||||||
|
ORDER BY name ASC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
items: list[BackupSummary] = []
|
||||||
|
for row in rows:
|
||||||
|
source_host = row["source_host"] or "-"
|
||||||
|
source_share = row["source_share"] or ""
|
||||||
|
source_subpath = row["source_subpath"] or "/"
|
||||||
|
destination_host = row["destination_host"] or "-"
|
||||||
|
destination_share = row["destination_share"] or ""
|
||||||
|
destination_subpath = row["destination_subpath"] or "/"
|
||||||
|
|
||||||
|
source_port = row["source_port"]
|
||||||
|
destination_port = row["destination_port"]
|
||||||
|
source_port_suffix = f":{source_port}" if source_port else ""
|
||||||
|
destination_port_suffix = f":{destination_port}" if destination_port else ""
|
||||||
|
|
||||||
|
source_label = (
|
||||||
|
f"{row['source_protocol']}://{source_host}{source_port_suffix}/"
|
||||||
|
f"{source_share}{source_subpath}"
|
||||||
|
)
|
||||||
|
destination_label = (
|
||||||
|
f"{row['destination_protocol']}://{destination_host}{destination_port_suffix}/"
|
||||||
|
f"{destination_share}{destination_subpath}"
|
||||||
|
)
|
||||||
|
items.append(
|
||||||
|
BackupSummary(
|
||||||
|
backup_id=row["id"],
|
||||||
|
name=row["name"],
|
||||||
|
source_label=source_label,
|
||||||
|
destination_label=destination_label,
|
||||||
|
target_pattern=row["target_pattern"],
|
||||||
|
compression_method=row["compression_method"],
|
||||||
|
schedule_mode=row["schedule_mode"] if row["schedule_mode"] else "daily",
|
||||||
|
last_run_at=row["last_run_at"],
|
||||||
|
last_size_bytes=row["last_size_bytes"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
def get_backup_edit_data(self, backup_id: int) -> dict | None:
|
||||||
|
with self._connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
target_pattern,
|
||||||
|
compression_method,
|
||||||
|
encryption_mode,
|
||||||
|
schedule_mode,
|
||||||
|
schedule_time,
|
||||||
|
schedule_weekday,
|
||||||
|
schedule_day_of_month,
|
||||||
|
schedule_interval,
|
||||||
|
schedule_cron
|
||||||
|
FROM backups
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(backup_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"target_pattern": row["target_pattern"],
|
||||||
|
"compression_method": row["compression_method"],
|
||||||
|
"encryption_mode": row["encryption_mode"],
|
||||||
|
"schedule_mode": row["schedule_mode"],
|
||||||
|
"schedule_time": row["schedule_time"],
|
||||||
|
"schedule_weekday": row["schedule_weekday"],
|
||||||
|
"schedule_day_of_month": row["schedule_day_of_month"],
|
||||||
|
"schedule_interval": row["schedule_interval"],
|
||||||
|
"schedule_cron": row["schedule_cron"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_backup_execution_job(self, backup_id: int) -> BackupExecutionJob | None:
|
||||||
|
with self._connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
source_protocol,
|
||||||
|
source_host,
|
||||||
|
source_port,
|
||||||
|
source_share,
|
||||||
|
source_subpath,
|
||||||
|
source_username,
|
||||||
|
source_password_enc,
|
||||||
|
destination_protocol,
|
||||||
|
destination_host,
|
||||||
|
destination_port,
|
||||||
|
destination_share,
|
||||||
|
destination_subpath,
|
||||||
|
destination_username,
|
||||||
|
destination_password_enc
|
||||||
|
FROM backups
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(backup_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return BackupExecutionJob(
|
||||||
|
backup_id=row["id"],
|
||||||
|
name=row["name"],
|
||||||
|
source_protocol=row["source_protocol"],
|
||||||
|
source_host=row["source_host"],
|
||||||
|
source_port=row["source_port"],
|
||||||
|
source_share=row["source_share"],
|
||||||
|
source_subpath=row["source_subpath"],
|
||||||
|
source_username=row["source_username"],
|
||||||
|
source_password=self.crypto.decrypt_text(row["source_password_enc"]),
|
||||||
|
destination_protocol=row["destination_protocol"],
|
||||||
|
destination_host=row["destination_host"],
|
||||||
|
destination_port=row["destination_port"],
|
||||||
|
destination_share=row["destination_share"],
|
||||||
|
destination_subpath=row["destination_subpath"],
|
||||||
|
destination_username=row["destination_username"],
|
||||||
|
destination_password=self.crypto.decrypt_text(row["destination_password_enc"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_backup_edit_data(
|
||||||
|
self,
|
||||||
|
backup_id: int,
|
||||||
|
name: str,
|
||||||
|
target_pattern: str,
|
||||||
|
compression_method: str,
|
||||||
|
encryption_mode: str,
|
||||||
|
archive_password: str,
|
||||||
|
schedule_mode: str,
|
||||||
|
schedule_time: str,
|
||||||
|
schedule_weekday: str,
|
||||||
|
schedule_day_of_month: str,
|
||||||
|
schedule_interval: int | None,
|
||||||
|
schedule_cron: str,
|
||||||
|
) -> bool:
|
||||||
|
with self._connect() as conn:
|
||||||
|
try:
|
||||||
|
if archive_password:
|
||||||
|
result = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE backups
|
||||||
|
SET
|
||||||
|
name = ?,
|
||||||
|
target_pattern = ?,
|
||||||
|
compression_method = ?,
|
||||||
|
encryption_mode = ?,
|
||||||
|
archive_password_enc = ?,
|
||||||
|
schedule_mode = ?,
|
||||||
|
schedule_time = ?,
|
||||||
|
schedule_weekday = ?,
|
||||||
|
schedule_day_of_month = ?,
|
||||||
|
schedule_interval = ?,
|
||||||
|
schedule_cron = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
name,
|
||||||
|
target_pattern,
|
||||||
|
compression_method,
|
||||||
|
encryption_mode,
|
||||||
|
self.crypto.encrypt_text(archive_password),
|
||||||
|
schedule_mode,
|
||||||
|
schedule_time,
|
||||||
|
schedule_weekday,
|
||||||
|
schedule_day_of_month,
|
||||||
|
schedule_interval,
|
||||||
|
schedule_cron,
|
||||||
|
backup_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE backups
|
||||||
|
SET
|
||||||
|
name = ?,
|
||||||
|
target_pattern = ?,
|
||||||
|
compression_method = ?,
|
||||||
|
encryption_mode = ?,
|
||||||
|
schedule_mode = ?,
|
||||||
|
schedule_time = ?,
|
||||||
|
schedule_weekday = ?,
|
||||||
|
schedule_day_of_month = ?,
|
||||||
|
schedule_interval = ?,
|
||||||
|
schedule_cron = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
name,
|
||||||
|
target_pattern,
|
||||||
|
compression_method,
|
||||||
|
encryption_mode,
|
||||||
|
schedule_mode,
|
||||||
|
schedule_time,
|
||||||
|
schedule_weekday,
|
||||||
|
schedule_day_of_month,
|
||||||
|
schedule_interval,
|
||||||
|
schedule_cron,
|
||||||
|
backup_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return result.rowcount == 1
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def mark_backup_run(self, backup_id: int) -> bool:
|
||||||
|
with self._connect() as conn:
|
||||||
|
result = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE backups
|
||||||
|
SET
|
||||||
|
last_run_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'now', 'localtime'),
|
||||||
|
last_size_bytes = COALESCE(last_size_bytes, 0),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(backup_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return result.rowcount == 1
|
||||||
|
|
||||||
|
def delete_backup(self, backup_id: int) -> bool:
|
||||||
|
with self._connect() as conn:
|
||||||
|
result = conn.execute("DELETE FROM backups WHERE id = ?", (backup_id,))
|
||||||
|
conn.commit()
|
||||||
|
return result.rowcount == 1
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoManager:
|
||||||
|
def __init__(self, key_path: Path) -> None:
|
||||||
|
self.key_path = key_path
|
||||||
|
|
||||||
|
def _load_or_create_key(self) -> bytes:
|
||||||
|
if self.key_path.exists():
|
||||||
|
return self.key_path.read_bytes().strip()
|
||||||
|
|
||||||
|
self.key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
key = Fernet.generate_key()
|
||||||
|
self.key_path.write_bytes(key)
|
||||||
|
return key
|
||||||
|
|
||||||
|
def _fernet(self) -> Fernet:
|
||||||
|
return Fernet(self._load_or_create_key())
|
||||||
|
|
||||||
|
def encrypt_text(self, value: str) -> str:
|
||||||
|
token = self._fernet().encrypt(value.encode("utf-8"))
|
||||||
|
return token.decode("utf-8")
|
||||||
|
|
||||||
|
def decrypt_text(self, token: str) -> str:
|
||||||
|
value = self._fernet().decrypt(token.encode("utf-8"))
|
||||||
|
return value.decode("utf-8")
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class DependencyManager:
|
||||||
|
def __init__(self, requirements_path: Path) -> None:
|
||||||
|
self.requirements_path = requirements_path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_requirement_name(requirement_line: str) -> str:
|
||||||
|
line = requirement_line.split(";", 1)[0].strip()
|
||||||
|
for sep in ["==", ">=", "<=", "!=", "~=", ">", "<"]:
|
||||||
|
if sep in line:
|
||||||
|
line = line.split(sep, 1)[0].strip()
|
||||||
|
break
|
||||||
|
if "[" in line:
|
||||||
|
line = line.split("[", 1)[0].strip()
|
||||||
|
return line
|
||||||
|
|
||||||
|
def parse_requirements(self) -> list[str]:
|
||||||
|
if not self.requirements_path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
requirements: list[str] = []
|
||||||
|
for raw in self.requirements_path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
requirements.append(line)
|
||||||
|
return requirements
|
||||||
|
|
||||||
|
def _is_installed(self, requirement: str) -> bool:
|
||||||
|
package_name = self._normalize_requirement_name(requirement)
|
||||||
|
if not package_name:
|
||||||
|
return True
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "pip", "show", package_name],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
def install_missing(self) -> list[str]:
|
||||||
|
requirements = self.parse_requirements()
|
||||||
|
missing = [req for req in requirements if not self._is_installed(req)]
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
subprocess.check_call([sys.executable, "-m", "pip", "install", *missing])
|
||||||
|
|
||||||
|
return missing
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AppPaths:
|
||||||
|
base_dir: Path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def venv_dir(self) -> Path:
|
||||||
|
return self.base_dir / ".venv"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data_dir(self) -> Path:
|
||||||
|
return self.base_dir / "data"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def db_path(self) -> Path:
|
||||||
|
return self.data_dir / "config.db"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def requirements_path(self) -> Path:
|
||||||
|
return self.base_dir / "requirements.txt"
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from ftplib import FTP
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
from smb.SMBConnection import SMBConnection
|
||||||
|
import smbclient
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TargetConnection:
|
||||||
|
protocol: str
|
||||||
|
host: str
|
||||||
|
port: int | None
|
||||||
|
share: str
|
||||||
|
subpath: str
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_subpath(value: str) -> str:
|
||||||
|
path = value.strip()
|
||||||
|
if not path:
|
||||||
|
return "/"
|
||||||
|
if not path.startswith("/"):
|
||||||
|
return f"/{path}"
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _smb_list_path(path: str) -> str:
|
||||||
|
normalized = normalize_subpath(path)
|
||||||
|
trimmed = normalized.strip("/")
|
||||||
|
return "/" if not trimmed else trimmed
|
||||||
|
|
||||||
|
|
||||||
|
def _build_child_path(parent: str, name: str) -> str:
|
||||||
|
p = normalize_subpath(parent).rstrip("/")
|
||||||
|
if not p:
|
||||||
|
p = "/"
|
||||||
|
if p == "/":
|
||||||
|
return f"/{name}"
|
||||||
|
return f"{p}/{name}"
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_entries(items: list[dict[str, str]]) -> list[dict[str, str]]:
|
||||||
|
return sorted(items, key=lambda x: (0 if x["entry_type"] == "directory" else 1, x["name"].lower()))
|
||||||
|
|
||||||
|
|
||||||
|
def _smb_unc_path(target: TargetConnection, subpath: str) -> str:
|
||||||
|
share = target.share.strip().strip("\\/")
|
||||||
|
clean_subpath = normalize_subpath(subpath).strip("/")
|
||||||
|
if clean_subpath:
|
||||||
|
rel = clean_subpath.replace("/", "\\")
|
||||||
|
return f"\\\\{target.host}\\{share}\\{rel}"
|
||||||
|
return f"\\\\{target.host}\\{share}"
|
||||||
|
|
||||||
|
|
||||||
|
def _list_smb_shares_with_smbprotocol(target: TargetConnection) -> tuple[bool, str, list[str]]:
|
||||||
|
try:
|
||||||
|
smbclient.register_session(
|
||||||
|
server=target.host,
|
||||||
|
username=target.username,
|
||||||
|
password=target.password,
|
||||||
|
port=target.port or 445,
|
||||||
|
)
|
||||||
|
# Manche Server erlauben das Listing von \\host als Share-Quelle.
|
||||||
|
entries = smbclient.listdir(f"\\\\{target.host}\\")
|
||||||
|
shares = sorted({str(item).strip("\\/") for item in entries if str(item).strip("\\/")})
|
||||||
|
if shares:
|
||||||
|
return True, "SMB-Shares erfolgreich geladen.", shares
|
||||||
|
return False, "Keine Shares gefunden.", []
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return False, f"smbprotocol Fehler: {exc}", []
|
||||||
|
|
||||||
|
|
||||||
|
def _list_smb_shares_with_cli(target: TargetConnection) -> tuple[bool, str, list[str]]:
|
||||||
|
smbclient_bin = shutil.which("smbclient")
|
||||||
|
if smbclient_bin is None:
|
||||||
|
return False, "smbclient CLI ist nicht installiert.", []
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
smbclient_bin,
|
||||||
|
"-g",
|
||||||
|
"-L",
|
||||||
|
f"//{target.host}",
|
||||||
|
"-U",
|
||||||
|
f"{target.username}%{target.password}",
|
||||||
|
"-m",
|
||||||
|
"SMB3",
|
||||||
|
]
|
||||||
|
if target.port:
|
||||||
|
cmd.extend(["-p", str(target.port)])
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
err = (result.stderr or result.stdout or "Unbekannter Fehler").strip()
|
||||||
|
return False, f"smbclient Fehler: {err}", []
|
||||||
|
|
||||||
|
shares: list[str] = []
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
parts = [p.strip() for p in line.split("|")]
|
||||||
|
if len(parts) < 3:
|
||||||
|
continue
|
||||||
|
if parts[0].lower() != "disk":
|
||||||
|
continue
|
||||||
|
if parts[1]:
|
||||||
|
shares.append(parts[1])
|
||||||
|
|
||||||
|
uniq = sorted(set(shares))
|
||||||
|
if not uniq:
|
||||||
|
return False, "Es wurden keine Disk-Shares gefunden.", []
|
||||||
|
return True, "SMB-Shares erfolgreich geladen.", uniq
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return False, f"smbclient Aufruf fehlgeschlagen: {exc}", []
|
||||||
|
|
||||||
|
|
||||||
|
def list_smb_shares(target: TargetConnection) -> tuple[bool, str, list[str]]:
|
||||||
|
if target.protocol.upper() != "SMB":
|
||||||
|
return False, "Share-Liste ist nur fuer SMB verfuegbar.", []
|
||||||
|
if not target.host or not target.username or not target.password:
|
||||||
|
return False, "Fuer Share-Liste werden Host, Benutzer und Passwort benoetigt.", []
|
||||||
|
|
||||||
|
ok, msg, shares = _list_smb_shares_with_smbprotocol(target)
|
||||||
|
if ok:
|
||||||
|
return True, msg, shares
|
||||||
|
|
||||||
|
ok2, msg2, shares2 = _list_smb_shares_with_cli(target)
|
||||||
|
if ok2:
|
||||||
|
return True, msg2, shares2
|
||||||
|
|
||||||
|
return False, f"Share-Laden fehlgeschlagen. smbprotocol: {msg} | smbclient: {msg2}", []
|
||||||
|
|
||||||
|
|
||||||
|
def _test_smb_with_smbprotocol(target: TargetConnection) -> tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
smbclient.register_session(
|
||||||
|
server=target.host,
|
||||||
|
username=target.username,
|
||||||
|
password=target.password,
|
||||||
|
port=target.port or 445,
|
||||||
|
)
|
||||||
|
unc = _smb_unc_path(target, target.subpath)
|
||||||
|
smbclient.listdir(unc)
|
||||||
|
return True, "SMB-Verbindung und Pfad sind erreichbar."
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return False, f"smbprotocol Fehler: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
def _test_smb_with_pysmb(target: TargetConnection) -> tuple[bool, str]:
|
||||||
|
conn = SMBConnection(
|
||||||
|
target.username,
|
||||||
|
target.password,
|
||||||
|
"stfv-backup-client",
|
||||||
|
"stfv-backup-server",
|
||||||
|
use_ntlm_v2=True,
|
||||||
|
is_direct_tcp=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ok = conn.connect(target.host, target.port or 445, timeout=8)
|
||||||
|
if not ok:
|
||||||
|
return False, "pysmb: SMB-Verbindung konnte nicht aufgebaut werden."
|
||||||
|
conn.listPath(target.share, _smb_list_path(target.subpath))
|
||||||
|
return True, "SMB-Verbindung und Pfad sind erreichbar."
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return False, f"pysmb Fehler: {exc}"
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _browse_smb_with_smbprotocol(target: TargetConnection) -> tuple[bool, str, list[dict[str, str]]]:
|
||||||
|
current_path = normalize_subpath(target.subpath)
|
||||||
|
try:
|
||||||
|
smbclient.register_session(
|
||||||
|
server=target.host,
|
||||||
|
username=target.username,
|
||||||
|
password=target.password,
|
||||||
|
port=target.port or 445,
|
||||||
|
)
|
||||||
|
unc = _smb_unc_path(target, current_path)
|
||||||
|
entries_out: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
if current_path != "/":
|
||||||
|
parent = "/" + "/".join(current_path.strip("/").split("/")[:-1])
|
||||||
|
parent = parent if parent else "/"
|
||||||
|
entries_out.append({"name": "..", "path": parent, "entry_type": "up"})
|
||||||
|
|
||||||
|
for item in smbclient.scandir(unc):
|
||||||
|
name = item.name
|
||||||
|
if name in {".", ".."}:
|
||||||
|
continue
|
||||||
|
entry_type = "directory" if item.is_dir() else "file"
|
||||||
|
entries_out.append({"name": name, "path": _build_child_path(current_path, name), "entry_type": entry_type})
|
||||||
|
|
||||||
|
ups = [x for x in entries_out if x["entry_type"] == "up"]
|
||||||
|
normal = [x for x in entries_out if x["entry_type"] != "up"]
|
||||||
|
return True, current_path, ups + _sort_entries(normal)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return False, f"smbprotocol Fehler: {exc}", []
|
||||||
|
|
||||||
|
|
||||||
|
def _browse_smb_with_pysmb(target: TargetConnection) -> tuple[bool, str, list[dict[str, str]]]:
|
||||||
|
conn = SMBConnection(
|
||||||
|
target.username,
|
||||||
|
target.password,
|
||||||
|
"stfv-backup-client",
|
||||||
|
"stfv-backup-server",
|
||||||
|
use_ntlm_v2=True,
|
||||||
|
is_direct_tcp=True,
|
||||||
|
)
|
||||||
|
current_path = normalize_subpath(target.subpath)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ok = conn.connect(target.host, target.port or 445, timeout=8)
|
||||||
|
if not ok:
|
||||||
|
return False, "pysmb: SMB-Verbindung konnte nicht aufgebaut werden.", []
|
||||||
|
|
||||||
|
entries = conn.listPath(target.share, _smb_list_path(current_path))
|
||||||
|
entries_out: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
if current_path != "/":
|
||||||
|
parent = "/" + "/".join(current_path.strip("/").split("/")[:-1])
|
||||||
|
parent = parent if parent else "/"
|
||||||
|
entries_out.append({"name": "..", "path": parent, "entry_type": "up"})
|
||||||
|
|
||||||
|
for item in entries:
|
||||||
|
if item.filename in {".", ".."}:
|
||||||
|
continue
|
||||||
|
entry_type = "directory" if item.isDirectory else "file"
|
||||||
|
entries_out.append({"name": item.filename, "path": _build_child_path(current_path, item.filename), "entry_type": entry_type})
|
||||||
|
|
||||||
|
ups = [x for x in entries_out if x["entry_type"] == "up"]
|
||||||
|
normal = [x for x in entries_out if x["entry_type"] != "up"]
|
||||||
|
return True, current_path, ups + _sort_entries(normal)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return False, f"pysmb Fehler: {exc}", []
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection(target: TargetConnection) -> tuple[bool, str]:
|
||||||
|
protocol = target.protocol.upper()
|
||||||
|
|
||||||
|
if protocol == "LOCAL":
|
||||||
|
check_path = Path(target.subpath)
|
||||||
|
if check_path.exists() and check_path.is_dir():
|
||||||
|
return True, "Lokaler Pfad erreichbar."
|
||||||
|
return False, "Lokaler Pfad ist nicht erreichbar oder kein Verzeichnis."
|
||||||
|
|
||||||
|
if protocol == "SMB":
|
||||||
|
if not target.share:
|
||||||
|
return False, "SMB-Share fehlt."
|
||||||
|
ok, msg = _test_smb_with_smbprotocol(target)
|
||||||
|
if ok:
|
||||||
|
return True, msg
|
||||||
|
ok2, msg2 = _test_smb_with_pysmb(target)
|
||||||
|
if ok2:
|
||||||
|
return True, msg2
|
||||||
|
return False, f"SMB-Test fehlgeschlagen. smbprotocol: {msg} | pysmb: {msg2}"
|
||||||
|
|
||||||
|
if protocol == "FTP":
|
||||||
|
ftp = FTP()
|
||||||
|
try:
|
||||||
|
ftp.connect(target.host, target.port or 21, timeout=8)
|
||||||
|
ftp.login(target.username, target.password)
|
||||||
|
ftp.cwd(target.subpath or "/")
|
||||||
|
return True, "FTP-Verbindung und Pfad sind erreichbar."
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return False, f"FTP-Test fehlgeschlagen: {exc}"
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
ftp.quit()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
if protocol == "SFTP":
|
||||||
|
transport = None
|
||||||
|
sftp = None
|
||||||
|
try:
|
||||||
|
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 "/")
|
||||||
|
return True, "SFTP-Verbindung und Pfad sind erreichbar."
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return False, f"SFTP-Test fehlgeschlagen: {exc}"
|
||||||
|
finally:
|
||||||
|
if sftp is not None:
|
||||||
|
sftp.close()
|
||||||
|
if transport is not None:
|
||||||
|
transport.close()
|
||||||
|
|
||||||
|
return False, "Unbekanntes Protokoll."
|
||||||
|
|
||||||
|
|
||||||
|
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.", []
|
||||||
|
if not target.share:
|
||||||
|
return False, "SMB-Share fehlt.", []
|
||||||
|
|
||||||
|
ok, path_or_error, folders = _browse_smb_with_smbprotocol(target)
|
||||||
|
if ok:
|
||||||
|
return True, path_or_error, folders
|
||||||
|
|
||||||
|
ok2, path_or_error2, folders2 = _browse_smb_with_pysmb(target)
|
||||||
|
if ok2:
|
||||||
|
return True, path_or_error2, folders2
|
||||||
|
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
"SMB-Browsing fehlgeschlagen. "
|
||||||
|
f"smbprotocol: {path_or_error} | pysmb: {path_or_error2}",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_smb_directory(target: TargetConnection, base_path: str, folder_name: str) -> tuple[bool, str]:
|
||||||
|
if target.protocol.upper() != "SMB":
|
||||||
|
return False, "Neuer Ordner kann nur bei SMB erstellt werden."
|
||||||
|
clean_name = folder_name.strip().strip("/").strip("\\")
|
||||||
|
if not clean_name:
|
||||||
|
return False, "Ordnername darf nicht leer sein."
|
||||||
|
if "/" in clean_name or "\\" in clean_name:
|
||||||
|
return False, "Ordnername darf keine Pfadtrenner enthalten."
|
||||||
|
|
||||||
|
parent = normalize_subpath(base_path)
|
||||||
|
full_path = _build_child_path(parent, clean_name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
smbclient.register_session(
|
||||||
|
server=target.host,
|
||||||
|
username=target.username,
|
||||||
|
password=target.password,
|
||||||
|
port=target.port or 445,
|
||||||
|
)
|
||||||
|
smbclient.mkdir(_smb_unc_path(target, full_path))
|
||||||
|
return True, full_path
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
# Fallback via pysmb
|
||||||
|
conn = SMBConnection(
|
||||||
|
target.username,
|
||||||
|
target.password,
|
||||||
|
"stfv-backup-client",
|
||||||
|
"stfv-backup-server",
|
||||||
|
use_ntlm_v2=True,
|
||||||
|
is_direct_tcp=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ok = conn.connect(target.host, target.port or 445, timeout=8)
|
||||||
|
if not ok:
|
||||||
|
return False, f"SMB-Verbindung fehlgeschlagen: {exc}"
|
||||||
|
conn.createDirectory(target.share, _smb_list_path(full_path))
|
||||||
|
return True, full_path
|
||||||
|
except Exception as exc2: # noqa: BLE001
|
||||||
|
return False, f"Ordner konnte nicht erstellt werden. smbprotocol: {exc} | pysmb: {exc2}"
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str, iterations: int = 200_000) -> str:
|
||||||
|
salt = secrets.token_bytes(16)
|
||||||
|
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
|
||||||
|
return f"pbkdf2_sha256${iterations}${salt.hex()}${digest.hex()}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, password_hash: str) -> bool:
|
||||||
|
try:
|
||||||
|
algorithm, rounds_raw, salt_hex, digest_hex = password_hash.split("$", 3)
|
||||||
|
if algorithm != "pbkdf2_sha256":
|
||||||
|
return False
|
||||||
|
rounds = int(rounds_raw)
|
||||||
|
salt = bytes.fromhex(salt_hex)
|
||||||
|
expected = bytes.fromhex(digest_hex)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
candidate = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, rounds)
|
||||||
|
return secrets.compare_digest(candidate, expected)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import getpass
|
||||||
|
|
||||||
|
from app.config_model import AppConfig, InitialSetupData
|
||||||
|
from app.security import hash_password
|
||||||
|
|
||||||
|
|
||||||
|
class SetupWizard:
|
||||||
|
@staticmethod
|
||||||
|
def _ask_non_empty(prompt: str) -> str:
|
||||||
|
while True:
|
||||||
|
value = input(prompt).strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
print("Eingabe darf nicht leer sein.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ask_port(prompt: str) -> int:
|
||||||
|
while True:
|
||||||
|
raw = input(prompt).strip()
|
||||||
|
try:
|
||||||
|
port = int(raw)
|
||||||
|
if 1 <= port <= 65535:
|
||||||
|
return port
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
print("Bitte eine gueltige Portnummer zwischen 1 und 65535 eingeben.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ask_debug(prompt: str) -> bool:
|
||||||
|
while True:
|
||||||
|
raw = input(prompt).strip().lower()
|
||||||
|
if raw in {"true", "false"}:
|
||||||
|
return raw == "true"
|
||||||
|
print("Bitte nur true oder false eingeben.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ask_password() -> str:
|
||||||
|
while True:
|
||||||
|
password = getpass.getpass("Adminpassword: ")
|
||||||
|
confirm = getpass.getpass("Adminpassword bestaetigen: ")
|
||||||
|
if not password:
|
||||||
|
print("Passwort darf nicht leer sein.")
|
||||||
|
continue
|
||||||
|
if password != confirm:
|
||||||
|
print("Passwoerter stimmen nicht ueberein.")
|
||||||
|
continue
|
||||||
|
return password
|
||||||
|
|
||||||
|
def collect_initial(self) -> InitialSetupData:
|
||||||
|
print("\n[SETUP] Initiale Konfiguration erforderlich.")
|
||||||
|
|
||||||
|
ip = self._ask_non_empty("IP-Adresse: ")
|
||||||
|
port = self._ask_port("Port: ")
|
||||||
|
debug = self._ask_debug("Debug (true/false): ")
|
||||||
|
adminuser = self._ask_non_empty("Adminuser: ")
|
||||||
|
password_hash = hash_password(self._ask_password())
|
||||||
|
|
||||||
|
return InitialSetupData(
|
||||||
|
config=AppConfig(
|
||||||
|
ip=ip,
|
||||||
|
port=port,
|
||||||
|
debug=debug,
|
||||||
|
),
|
||||||
|
admin_username=adminuser,
|
||||||
|
admin_password_hash=password_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
def collect_admin_user(self) -> tuple[str, str]:
|
||||||
|
print("\n[SETUP] Es wurde kein Benutzer gefunden. Bitte Admin anlegen.")
|
||||||
|
adminuser = self._ask_non_empty("Adminuser: ")
|
||||||
|
password_hash = hash_password(self._ask_password())
|
||||||
|
return adminuser, password_hash
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends "base_admin.html" %}
|
||||||
|
|
||||||
|
{% block title %}StFV Backup - Admin Konto{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Admin Konto</h2>
|
||||||
|
<p>Hier kannst du deinen Benutzernamen und dein Passwort komplett aendern.</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<form method="post" action="{{ url_for('account_settings') }}">
|
||||||
|
<label for="new_username">Neuer Benutzername</label>
|
||||||
|
<input id="new_username" name="new_username" type="text" value="{{ adminuser }}" required>
|
||||||
|
|
||||||
|
<label for="current_password">Aktuelles Passwort</label>
|
||||||
|
<input id="current_password" name="current_password" type="password" autocomplete="current-password" required>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="new_password">Neues Passwort</label>
|
||||||
|
<input id="new_password" name="new_password" type="password" autocomplete="new-password" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="confirm_password">Neues Passwort bestaetigen</label>
|
||||||
|
<input id="confirm_password" name="confirm_password" type="password" autocomplete="new-password" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn" type="submit">Admin Konto aktualisieren</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
{% extends "base_admin.html" %}
|
||||||
|
|
||||||
|
{% block title %}StFV Backup - Backup bearbeiten{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Backup bearbeiten</h2>
|
||||||
|
<p>Hier kannst du Name, Ziel, Verschlüsselung und Zeitplan anpassen.</p>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('backup_edit', backup_id=backup.id) }}">
|
||||||
|
<div class="card">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="name" name="name" type="text" value="{{ backup.name }}" required>
|
||||||
|
|
||||||
|
<label for="target_pattern">Zielordner / Zieldatei</label>
|
||||||
|
<input id="target_pattern" name="target_pattern" type="text" value="{{ backup.target_pattern }}" required>
|
||||||
|
|
||||||
|
<label for="compression_method">Kompression</label>
|
||||||
|
<select id="compression_method" name="compression_method" required>
|
||||||
|
{% for method in compression_methods %}
|
||||||
|
<option value="{{ method }}" {% if method == backup.compression_method %}selected{% endif %}>{{ method }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="encryption_mode">Verschlüsselung</label>
|
||||||
|
<select id="encryption_mode" name="encryption_mode" required>
|
||||||
|
{% for mode in encryption_modes %}
|
||||||
|
<option value="{{ mode }}" {% if mode == backup.encryption_mode %}selected{% endif %}>{{ mode }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div id="password_box">
|
||||||
|
<label for="archive_password">Neues Passwort (optional)</label>
|
||||||
|
<input id="archive_password" name="archive_password" type="password" placeholder="Leer lassen = bestehendes Passwort behalten">
|
||||||
|
|
||||||
|
<label for="archive_password_confirm">Passwort bestätigen</label>
|
||||||
|
<input id="archive_password_confirm" name="archive_password_confirm" type="password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Zeitplan</h3>
|
||||||
|
<label for="schedule_mode">Ausführung</label>
|
||||||
|
<select id="schedule_mode" name="schedule_mode" required>
|
||||||
|
{% for mode in schedule_modes %}
|
||||||
|
<option value="{{ mode }}" {% if mode == backup.schedule_mode %}selected{% endif %}>{{ mode }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div id="schedule_time_box">
|
||||||
|
<label for="schedule_time">Uhrzeit</label>
|
||||||
|
<input id="schedule_time" name="schedule_time" type="time" value="{{ backup.schedule_time or '02:00' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="schedule_weekday_box" style="display:none;">
|
||||||
|
<label for="schedule_weekday">Wochentag</label>
|
||||||
|
<select id="schedule_weekday" name="schedule_weekday">
|
||||||
|
<option value="1" {% if backup.schedule_weekday == '1' %}selected{% endif %}>Montag</option>
|
||||||
|
<option value="2" {% if backup.schedule_weekday == '2' %}selected{% endif %}>Dienstag</option>
|
||||||
|
<option value="3" {% if backup.schedule_weekday == '3' %}selected{% endif %}>Mittwoch</option>
|
||||||
|
<option value="4" {% if backup.schedule_weekday == '4' %}selected{% endif %}>Donnerstag</option>
|
||||||
|
<option value="5" {% if backup.schedule_weekday == '5' %}selected{% endif %}>Freitag</option>
|
||||||
|
<option value="6" {% if backup.schedule_weekday == '6' %}selected{% endif %}>Samstag</option>
|
||||||
|
<option value="0" {% if backup.schedule_weekday == '0' %}selected{% endif %}>Sonntag</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="schedule_dom_box" style="display:none;">
|
||||||
|
<label for="schedule_day_of_month">Tag im Monat</label>
|
||||||
|
<input id="schedule_day_of_month" name="schedule_day_of_month" type="number" min="1" max="31" value="{{ backup.schedule_day_of_month or '1' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="schedule_interval_box" style="display:none;">
|
||||||
|
<label for="schedule_interval">Intervall (X)</label>
|
||||||
|
<input id="schedule_interval" name="schedule_interval" type="number" min="1" value="{{ backup.schedule_interval or 1 }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="schedule_cron_box" style="display:none;">
|
||||||
|
<label for="schedule_cron">CRON Ausdruck</label>
|
||||||
|
<input id="schedule_cron" name="schedule_cron" type="text" value="{{ backup.schedule_cron or '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-actions">
|
||||||
|
<a class="btn btn-secondary" href="{{ url_for('dashboard') }}" style="text-decoration:none;">Abbrechen</a>
|
||||||
|
<button class="btn" type="submit">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function syncEncryptionFields() {
|
||||||
|
const mode = document.getElementById('encryption_mode').value;
|
||||||
|
const box = document.getElementById('password_box');
|
||||||
|
box.style.display = mode === 'password-aes256' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncScheduleFields() {
|
||||||
|
const mode = document.getElementById('schedule_mode').value;
|
||||||
|
document.getElementById('schedule_weekday_box').style.display = mode === 'weekly' || mode === 'every_n_weeks' ? 'block' : 'none';
|
||||||
|
document.getElementById('schedule_dom_box').style.display = mode === 'monthly' || mode === 'yearly' ? 'block' : 'none';
|
||||||
|
document.getElementById('schedule_interval_box').style.display = mode === 'every_n_days' || mode === 'every_n_weeks' ? 'block' : 'none';
|
||||||
|
document.getElementById('schedule_cron_box').style.display = mode === 'custom' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('encryption_mode').addEventListener('change', syncEncryptionFields);
|
||||||
|
document.getElementById('schedule_mode').addEventListener('change', syncScheduleFields);
|
||||||
|
syncEncryptionFields();
|
||||||
|
syncScheduleFields();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,714 @@
|
|||||||
|
{% extends "base_admin.html" %}
|
||||||
|
|
||||||
|
{% block title %}StFV Backup - Neues Backup{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Neues Backup einrichten</h2>
|
||||||
|
<p>Die Konfiguration erscheint Schritt fuer Schritt, damit du jedes Fenster nacheinander ausfuellen kannst.</p>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wizard-step {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-step.active {
|
||||||
|
display: block;
|
||||||
|
animation: fade-slide 240ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-nav {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-pill {
|
||||||
|
border: 1px solid rgba(30, 42, 59, 0.2);
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #5a6e87;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-pill.active {
|
||||||
|
border-color: rgba(10, 127, 104, 0.35);
|
||||||
|
background: #e5fff7;
|
||||||
|
color: #0a7f68;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-actions {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
border: 1px solid rgba(30, 42, 59, 0.2);
|
||||||
|
background: #fff;
|
||||||
|
color: #1e2a3b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree {
|
||||||
|
list-style: none;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 0 0 0 8px;
|
||||||
|
border-left: 1px solid rgba(30, 42, 59, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree li {
|
||||||
|
margin: 4px 0;
|
||||||
|
padding-left: 10px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree li::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -8px;
|
||||||
|
top: 12px;
|
||||||
|
width: 8px;
|
||||||
|
border-top: 1px solid rgba(30, 42, 59, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #1e2a3b;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 3px 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node:hover {
|
||||||
|
background: #eef6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node.active {
|
||||||
|
background: #e5fff7;
|
||||||
|
color: #0a7f68;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5a6e87;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-empty {
|
||||||
|
color: #5a6e87;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-input {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-input input {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-help {
|
||||||
|
color: #5a6e87;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-slide {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('backup_new') }}" id="backupForm">
|
||||||
|
<input type="hidden" id="source_entry_type" name="source_entry_type" value="directory">
|
||||||
|
<input type="hidden" id="target_kind" name="target_kind" value="folder">
|
||||||
|
|
||||||
|
<div class="wizard-nav" id="wizardNav">
|
||||||
|
<span class="wizard-pill active" data-step-pill="0">Allgemein</span>
|
||||||
|
<span class="wizard-pill" data-step-pill="1">Source</span>
|
||||||
|
<span class="wizard-pill" data-step-pill="2">Destination</span>
|
||||||
|
<span class="wizard-pill" data-step-pill="3">Target</span>
|
||||||
|
<span class="wizard-pill" data-step-pill="4">Zeitplan</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card wizard-step active" data-step="0">
|
||||||
|
<h3>Allgemein</h3>
|
||||||
|
<label for="name">Backup Name</label>
|
||||||
|
<input id="name" name="name" type="text" placeholder="z. B. unraid-nextcloud-daily" required>
|
||||||
|
<div class="step-actions">
|
||||||
|
<button type="button" class="btn" onclick="nextStep(0)">Weiter</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card wizard-step" data-step="1">
|
||||||
|
<h3>Source</h3>
|
||||||
|
<label for="source_protocol">Typ</label>
|
||||||
|
<select id="source_protocol" name="source_protocol" required>
|
||||||
|
{% for protocol in target_protocols %}
|
||||||
|
<option value="{{ protocol }}">{{ protocol }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div id="source_remote_fields">
|
||||||
|
<label for="source_host">Host/IP (optional mit :Port)</label>
|
||||||
|
<input id="source_host" name="source_host" type="text" placeholder="192.168.1.20 oder 192.168.1.20:445">
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="source_username">Benutzer</label>
|
||||||
|
<input id="source_username" name="source_username" type="text">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="source_password">Passwort</label>
|
||||||
|
<input id="source_password" name="source_password" type="password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="source_share_row">
|
||||||
|
<label for="source_share">Share / Root</label>
|
||||||
|
<input id="source_share" name="source_share" type="text" placeholder="source-share">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label for="source_subpath">Unterpfad (SMB Browser)</label>
|
||||||
|
<input id="source_subpath" name="source_subpath" type="text" placeholder="/daten/projektA" value="/">
|
||||||
|
<p id="source_test_message"></p>
|
||||||
|
<div id="source_browser" class="card" style="display:none; margin-top:10px;">
|
||||||
|
<div id="source_browser_meta" class="tree-meta"></div>
|
||||||
|
<ul id="source_browser_tree" class="tree"></ul>
|
||||||
|
</div>
|
||||||
|
<p class="inline-help">Ordnerauswahl setzt Source als Ordner, Dateiauswahl setzt Source als Datei.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="source_local_fields" style="display:none;">
|
||||||
|
<label for="source_local_path">Lokaler Pfad</label>
|
||||||
|
<input id="source_local_path" type="text" placeholder="/mnt/storage/quellordner">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="prevStep(1)">Zurueck</button>
|
||||||
|
<button type="button" class="btn" onclick="nextStep(1)">Weiter</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card wizard-step" data-step="2">
|
||||||
|
<h3>Destination</h3>
|
||||||
|
<label for="destination_protocol">Typ</label>
|
||||||
|
<select id="destination_protocol" name="destination_protocol" required>
|
||||||
|
{% for protocol in target_protocols %}
|
||||||
|
<option value="{{ protocol }}">{{ protocol }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div id="destination_remote_fields">
|
||||||
|
<label for="destination_host">Host/IP (optional mit :Port)</label>
|
||||||
|
<input id="destination_host" name="destination_host" type="text" placeholder="192.168.1.30 oder 192.168.1.30:445">
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="destination_username">Benutzer</label>
|
||||||
|
<input id="destination_username" name="destination_username" type="text">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="destination_password">Passwort</label>
|
||||||
|
<input id="destination_password" name="destination_password" type="password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="destination_share_row">
|
||||||
|
<label for="destination_share">Share / Root</label>
|
||||||
|
<input id="destination_share" name="destination_share" type="text" placeholder="backup-share">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label for="destination_subpath">Unterpfad (SMB Browser)</label>
|
||||||
|
<input id="destination_subpath" name="destination_subpath" type="text" placeholder="/backups" value="/">
|
||||||
|
<div class="mini-input">
|
||||||
|
<input id="destination_new_folder" type="text" placeholder="Neuer Ordnername">
|
||||||
|
<button class="btn" type="button" onclick="createDestinationFolder()">Ordner erstellen</button>
|
||||||
|
</div>
|
||||||
|
<p id="destination_test_message"></p>
|
||||||
|
<div id="destination_browser" class="card" style="display:none; margin-top:10px;">
|
||||||
|
<div id="destination_browser_meta" class="tree-meta"></div>
|
||||||
|
<ul id="destination_browser_tree" class="tree"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="destination_local_fields" style="display:none;">
|
||||||
|
<label for="destination_local_path">Lokaler Pfad</label>
|
||||||
|
<input id="destination_local_path" type="text" placeholder="/mnt/storage/zielordner">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="prevStep(2)">Zurueck</button>
|
||||||
|
<button type="button" class="btn" onclick="nextStep(2)">Weiter</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card wizard-step" data-step="3">
|
||||||
|
<h3>Target</h3>
|
||||||
|
<label for="target_pattern" id="target_pattern_label">Zielordner</label>
|
||||||
|
<input id="target_pattern" name="target_pattern" type="text" placeholder="backup_{date}_{time}" required>
|
||||||
|
<p class="inline-help">Wenn in Source eine Datei gewaehlt wurde, ist dies die Zieldatei. Bei Ordnerauswahl ist dies der Zielordner.</p>
|
||||||
|
|
||||||
|
<label for="compression_method">Kompression</label>
|
||||||
|
<select id="compression_method" name="compression_method" required>
|
||||||
|
{% for method in compression_methods %}
|
||||||
|
<option value="{{ method }}">{{ method }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="encryption_mode">Verschluesselung</label>
|
||||||
|
<select id="encryption_mode" name="encryption_mode" required>
|
||||||
|
{% for mode in encryption_modes %}
|
||||||
|
<option value="{{ mode }}">{{ mode }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div id="encryption_password_box">
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="archive_password">Passwort</label>
|
||||||
|
<input id="archive_password" name="archive_password" type="password">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="archive_password_confirm">Passwort bestaetigen</label>
|
||||||
|
<input id="archive_password_confirm" name="archive_password_confirm" type="password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="inline-help">Passwoerter werden verschluesselt gespeichert.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="prevStep(3)">Zurueck</button>
|
||||||
|
<button type="button" class="btn" onclick="nextStep(3)">Weiter</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card wizard-step" data-step="4">
|
||||||
|
<h3>Zeitplan</h3>
|
||||||
|
<label for="schedule_mode">Ausfuehrung</label>
|
||||||
|
<select id="schedule_mode" name="schedule_mode" required>
|
||||||
|
<option value="daily">Taeglich</option>
|
||||||
|
<option value="weekly">Woechentlich</option>
|
||||||
|
<option value="monthly">Monatlich</option>
|
||||||
|
<option value="yearly">Jaehrlich</option>
|
||||||
|
<option value="every_n_days">Alle X Tage</option>
|
||||||
|
<option value="every_n_weeks">Alle X Wochen</option>
|
||||||
|
<option value="custom">Benutzerdefiniert (CRON)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div id="schedule_time_box">
|
||||||
|
<label for="schedule_time">Uhrzeit</label>
|
||||||
|
<input id="schedule_time" name="schedule_time" type="time" value="02:00">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="schedule_weekday_box" style="display:none;">
|
||||||
|
<label for="schedule_weekday">Wochentag</label>
|
||||||
|
<select id="schedule_weekday" name="schedule_weekday">
|
||||||
|
<option value="1">Montag</option>
|
||||||
|
<option value="2">Dienstag</option>
|
||||||
|
<option value="3">Mittwoch</option>
|
||||||
|
<option value="4">Donnerstag</option>
|
||||||
|
<option value="5">Freitag</option>
|
||||||
|
<option value="6">Samstag</option>
|
||||||
|
<option value="0">Sonntag</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="schedule_dom_box" style="display:none;">
|
||||||
|
<label for="schedule_day_of_month">Tag im Monat</label>
|
||||||
|
<input id="schedule_day_of_month" name="schedule_day_of_month" type="number" min="1" max="31" value="1">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="schedule_interval_box" style="display:none;">
|
||||||
|
<label for="schedule_interval">Intervall (X)</label>
|
||||||
|
<input id="schedule_interval" name="schedule_interval" type="number" min="1" value="1">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="schedule_cron_box" style="display:none;">
|
||||||
|
<label for="schedule_cron">CRON Ausdruck</label>
|
||||||
|
<input id="schedule_cron" name="schedule_cron" type="text" placeholder="0 2 * * *">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="prevStep(4)">Zurueck</button>
|
||||||
|
<button class="btn" type="submit">Backup speichern</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentStep = 0;
|
||||||
|
|
||||||
|
function htmlEscape(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStep(stepIdx) {
|
||||||
|
const steps = document.querySelectorAll('.wizard-step');
|
||||||
|
const pills = document.querySelectorAll('[data-step-pill]');
|
||||||
|
steps.forEach((el, idx) => el.classList.toggle('active', idx === stepIdx));
|
||||||
|
pills.forEach((el, idx) => el.classList.toggle('active', idx === stepIdx));
|
||||||
|
currentStep = stepIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireValue(id, message) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el || !el.value.trim()) {
|
||||||
|
alert(message);
|
||||||
|
el?.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateStep(stepIdx) {
|
||||||
|
if (stepIdx === 0) {
|
||||||
|
return requireValue('name', 'Bitte Backup Name setzen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepIdx === 1) {
|
||||||
|
const protocol = document.getElementById('source_protocol').value;
|
||||||
|
if (protocol === 'LOCAL') {
|
||||||
|
return requireValue('source_local_path', 'Bitte lokalen Source-Pfad angeben.');
|
||||||
|
}
|
||||||
|
if (!requireValue('source_host', 'Bitte Source Host/IP setzen.')) return false;
|
||||||
|
if (!requireValue('source_username', 'Bitte Source Benutzer setzen.')) return false;
|
||||||
|
if (!requireValue('source_password', 'Bitte Source Passwort setzen.')) return false;
|
||||||
|
if (protocol === 'SMB' && !requireValue('source_share', 'Bitte Source Share setzen.')) return false;
|
||||||
|
return requireValue('source_subpath', 'Bitte Source Unterpfad setzen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepIdx === 2) {
|
||||||
|
const protocol = document.getElementById('destination_protocol').value;
|
||||||
|
if (protocol === 'LOCAL') {
|
||||||
|
return requireValue('destination_local_path', 'Bitte lokalen Destination-Pfad angeben.');
|
||||||
|
}
|
||||||
|
if (!requireValue('destination_host', 'Bitte Destination Host/IP setzen.')) return false;
|
||||||
|
if (!requireValue('destination_username', 'Bitte Destination Benutzer setzen.')) return false;
|
||||||
|
if (!requireValue('destination_password', 'Bitte Destination Passwort setzen.')) return false;
|
||||||
|
if (protocol === 'SMB' && !requireValue('destination_share', 'Bitte Destination Share setzen.')) return false;
|
||||||
|
return requireValue('destination_subpath', 'Bitte Destination Unterpfad setzen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepIdx === 3) {
|
||||||
|
if (!requireValue('target_pattern', 'Bitte Zielordner oder Zieldatei setzen.')) return false;
|
||||||
|
const mode = document.getElementById('encryption_mode').value;
|
||||||
|
if (mode === 'password-aes256') {
|
||||||
|
if (!requireValue('archive_password', 'Bitte Verschluesselungs-Passwort setzen.')) return false;
|
||||||
|
if (!requireValue('archive_password_confirm', 'Bitte Passwort bestaetigen.')) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextStep(stepIdx) {
|
||||||
|
if (!validateStep(stepIdx)) return;
|
||||||
|
showStep(Math.min(stepIdx + 1, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevStep(stepIdx) {
|
||||||
|
showStep(Math.max(stepIdx - 1, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRequired(el, enabled) {
|
||||||
|
if (!el) return;
|
||||||
|
if (enabled) {
|
||||||
|
el.setAttribute('required', 'required');
|
||||||
|
} else {
|
||||||
|
el.removeAttribute('required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncProtocolFields(prefix) {
|
||||||
|
const protocol = document.getElementById(prefix + '_protocol').value;
|
||||||
|
const remoteFields = document.getElementById(prefix + '_remote_fields');
|
||||||
|
const localFields = document.getElementById(prefix + '_local_fields');
|
||||||
|
const shareRow = document.getElementById(prefix + '_share_row');
|
||||||
|
const host = document.getElementById(prefix + '_host');
|
||||||
|
const share = document.getElementById(prefix + '_share');
|
||||||
|
const subpath = document.getElementById(prefix + '_subpath');
|
||||||
|
const user = document.getElementById(prefix + '_username');
|
||||||
|
const pass = document.getElementById(prefix + '_password');
|
||||||
|
const localPath = document.getElementById(prefix + '_local_path');
|
||||||
|
|
||||||
|
if (protocol === 'LOCAL') {
|
||||||
|
remoteFields.style.display = 'none';
|
||||||
|
localFields.style.display = 'block';
|
||||||
|
shareRow.style.display = 'none';
|
||||||
|
setRequired(host, false);
|
||||||
|
setRequired(share, false);
|
||||||
|
setRequired(user, false);
|
||||||
|
setRequired(pass, false);
|
||||||
|
setRequired(localPath, true);
|
||||||
|
subpath.value = localPath.value || '';
|
||||||
|
share.value = '';
|
||||||
|
user.value = '';
|
||||||
|
pass.value = '';
|
||||||
|
host.value = 'local';
|
||||||
|
if (prefix === 'source') {
|
||||||
|
setSourceEntryType('directory');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteFields.style.display = 'block';
|
||||||
|
localFields.style.display = 'none';
|
||||||
|
shareRow.style.display = protocol === 'SMB' ? 'block' : 'none';
|
||||||
|
setRequired(host, true);
|
||||||
|
setRequired(share, protocol === 'SMB');
|
||||||
|
setRequired(user, true);
|
||||||
|
setRequired(pass, true);
|
||||||
|
setRequired(localPath, false);
|
||||||
|
|
||||||
|
if (protocol === 'FTP' || protocol === 'SFTP') {
|
||||||
|
if (!share.value) share.value = '/';
|
||||||
|
if (!subpath.value) subpath.value = '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPayload(prefix) {
|
||||||
|
const protocol = document.getElementById(prefix + '_protocol').value;
|
||||||
|
const payload = {
|
||||||
|
prefix: prefix,
|
||||||
|
[prefix + '_protocol']: protocol,
|
||||||
|
[prefix + '_host']: document.getElementById(prefix + '_host').value,
|
||||||
|
[prefix + '_share']: document.getElementById(prefix + '_share').value,
|
||||||
|
[prefix + '_subpath']: document.getElementById(prefix + '_subpath').value,
|
||||||
|
[prefix + '_username']: document.getElementById(prefix + '_username').value,
|
||||||
|
[prefix + '_password']: document.getElementById(prefix + '_password').value
|
||||||
|
};
|
||||||
|
|
||||||
|
if (protocol === 'LOCAL') {
|
||||||
|
payload[prefix + '_host'] = 'local';
|
||||||
|
payload[prefix + '_share'] = '';
|
||||||
|
payload[prefix + '_username'] = '';
|
||||||
|
payload[prefix + '_password'] = '';
|
||||||
|
payload[prefix + '_subpath'] = document.getElementById(prefix + '_local_path').value;
|
||||||
|
document.getElementById(prefix + '_subpath').value = payload[prefix + '_subpath'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearActiveTreeNodes(prefix) {
|
||||||
|
document
|
||||||
|
.querySelectorAll('#' + prefix + '_browser_tree .tree-node')
|
||||||
|
.forEach((node) => node.classList.remove('active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSourceEntryType(entryType) {
|
||||||
|
const t = entryType === 'file' ? 'file' : 'directory';
|
||||||
|
document.getElementById('source_entry_type').value = t;
|
||||||
|
const targetKind = t === 'file' ? 'file' : 'folder';
|
||||||
|
document.getElementById('target_kind').value = targetKind;
|
||||||
|
const label = document.getElementById('target_pattern_label');
|
||||||
|
label.textContent = targetKind === 'file' ? 'Zieldatei' : 'Zielordner';
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTreeItem(prefix, entry) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'tree-node';
|
||||||
|
|
||||||
|
let icon = '📁';
|
||||||
|
if (entry.entry_type === 'up') icon = '↩';
|
||||||
|
if (entry.entry_type === 'file') icon = '📄';
|
||||||
|
button.textContent = icon + ' ' + entry.name;
|
||||||
|
button.dataset.path = entry.path;
|
||||||
|
button.dataset.entryType = entry.entry_type;
|
||||||
|
|
||||||
|
const childList = document.createElement('ul');
|
||||||
|
childList.className = 'tree';
|
||||||
|
childList.style.display = 'none';
|
||||||
|
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
clearActiveTreeNodes(prefix);
|
||||||
|
button.classList.add('active');
|
||||||
|
document.getElementById(prefix + '_subpath').value = entry.path;
|
||||||
|
|
||||||
|
if (prefix === 'source') {
|
||||||
|
setSourceEntryType(entry.entry_type === 'file' ? 'file' : 'directory');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.entry_type === 'file') {
|
||||||
|
document.getElementById(prefix + '_test_message').textContent = 'Datei ausgewaehlt: ' + entry.path;
|
||||||
|
document.getElementById(prefix + '_test_message').style.color = '#0a7f68';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadTreeLevel(prefix, entry.path, childList);
|
||||||
|
});
|
||||||
|
|
||||||
|
li.appendChild(button);
|
||||||
|
li.appendChild(childList);
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTreeLevel(prefix, path, targetList) {
|
||||||
|
const payload = collectPayload(prefix);
|
||||||
|
payload[prefix + '_subpath'] = path;
|
||||||
|
|
||||||
|
const response = await fetch('{{ url_for("api_target_browse_smb") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const msg = document.getElementById(prefix + '_test_message');
|
||||||
|
if (!data.ok) {
|
||||||
|
msg.textContent = data.message || 'SMB-Browsing fehlgeschlagen.';
|
||||||
|
msg.style.color = '#b9382f';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.textContent = 'SMB-Verbindung erfolgreich.';
|
||||||
|
msg.style.color = '#0a7f68';
|
||||||
|
document.getElementById(prefix + '_subpath').value = data.path;
|
||||||
|
document.getElementById(prefix + '_browser_meta').textContent = 'Aktueller Pfad: ' + data.path;
|
||||||
|
|
||||||
|
targetList.innerHTML = '';
|
||||||
|
const entries = data.entries || [];
|
||||||
|
if (!entries.length) {
|
||||||
|
const empty = document.createElement('li');
|
||||||
|
empty.className = 'tree-empty';
|
||||||
|
empty.textContent = 'Keine Eintraege';
|
||||||
|
targetList.appendChild(empty);
|
||||||
|
targetList.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
targetList.appendChild(makeTreeItem(prefix, entry));
|
||||||
|
});
|
||||||
|
targetList.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderRootTree(prefix) {
|
||||||
|
const rootList = document.getElementById(prefix + '_browser_tree');
|
||||||
|
rootList.innerHTML = '';
|
||||||
|
await loadTreeLevel(prefix, document.getElementById(prefix + '_subpath').value || '/', rootList);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function browseSmb(prefix) {
|
||||||
|
if (document.getElementById(prefix + '_protocol').value !== 'SMB') {
|
||||||
|
const msg = document.getElementById(prefix + '_test_message');
|
||||||
|
msg.textContent = 'SMB Browser nur bei SMB aktiv.';
|
||||||
|
msg.style.color = '#b9382f';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.getElementById(prefix + '_browser');
|
||||||
|
container.style.display = 'block';
|
||||||
|
await renderRootTree(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDestinationFolder() {
|
||||||
|
const folderInput = document.getElementById('destination_new_folder');
|
||||||
|
const name = folderInput.value.trim();
|
||||||
|
const msg = document.getElementById('destination_test_message');
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
msg.textContent = 'Bitte zuerst einen neuen Ordnernamen eingeben.';
|
||||||
|
msg.style.color = '#b9382f';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = collectPayload('destination');
|
||||||
|
payload.folder_name = name;
|
||||||
|
|
||||||
|
const response = await fetch('{{ url_for("api_target_smb_mkdir") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.ok) {
|
||||||
|
msg.textContent = data.message || 'Ordner konnte nicht erstellt werden.';
|
||||||
|
msg.style.color = '#b9382f';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
folderInput.value = '';
|
||||||
|
document.getElementById('destination_subpath').value = data.path;
|
||||||
|
msg.textContent = 'Ordner erstellt: ' + data.path;
|
||||||
|
msg.style.color = '#0a7f68';
|
||||||
|
await browseSmb('destination');
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncEncryptionFields() {
|
||||||
|
const mode = document.getElementById('encryption_mode').value;
|
||||||
|
const box = document.getElementById('encryption_password_box');
|
||||||
|
const pwd = document.getElementById('archive_password');
|
||||||
|
const pwd2 = document.getElementById('archive_password_confirm');
|
||||||
|
const visible = mode === 'password-aes256';
|
||||||
|
box.style.display = visible ? 'block' : 'none';
|
||||||
|
setRequired(pwd, visible);
|
||||||
|
setRequired(pwd2, visible);
|
||||||
|
if (!visible) {
|
||||||
|
pwd.value = '';
|
||||||
|
pwd2.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncScheduleFields() {
|
||||||
|
const mode = document.getElementById('schedule_mode').value;
|
||||||
|
document.getElementById('schedule_weekday_box').style.display = mode === 'weekly' || mode === 'every_n_weeks' ? 'block' : 'none';
|
||||||
|
document.getElementById('schedule_dom_box').style.display = mode === 'monthly' || mode === 'yearly' ? 'block' : 'none';
|
||||||
|
document.getElementById('schedule_interval_box').style.display = mode === 'every_n_days' || mode === 'every_n_weeks' ? 'block' : 'none';
|
||||||
|
document.getElementById('schedule_cron_box').style.display = mode === 'custom' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('source_protocol').addEventListener('change', () => syncProtocolFields('source'));
|
||||||
|
document.getElementById('destination_protocol').addEventListener('change', () => syncProtocolFields('destination'));
|
||||||
|
document.getElementById('source_local_path').addEventListener('input', (ev) => {
|
||||||
|
document.getElementById('source_subpath').value = ev.target.value;
|
||||||
|
});
|
||||||
|
document.getElementById('destination_local_path').addEventListener('input', (ev) => {
|
||||||
|
document.getElementById('destination_subpath').value = ev.target.value;
|
||||||
|
});
|
||||||
|
document.getElementById('source_subpath').addEventListener('focus', () => {
|
||||||
|
if (document.getElementById('source_protocol').value === 'SMB') {
|
||||||
|
browseSmb('source');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('destination_subpath').addEventListener('focus', () => {
|
||||||
|
if (document.getElementById('destination_protocol').value === 'SMB') {
|
||||||
|
browseSmb('destination');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('source_subpath').addEventListener('click', () => {
|
||||||
|
if (document.getElementById('source_protocol').value === 'SMB') {
|
||||||
|
browseSmb('source');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('destination_subpath').addEventListener('click', () => {
|
||||||
|
if (document.getElementById('destination_protocol').value === 'SMB') {
|
||||||
|
browseSmb('destination');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('encryption_mode').addEventListener('change', syncEncryptionFields);
|
||||||
|
document.getElementById('schedule_mode').addEventListener('change', syncScheduleFields);
|
||||||
|
|
||||||
|
syncProtocolFields('source');
|
||||||
|
syncProtocolFields('destination');
|
||||||
|
syncEncryptionFields();
|
||||||
|
syncScheduleFields();
|
||||||
|
setSourceEntryType('directory');
|
||||||
|
showStep(0);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}StFV Backup{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--paper: #f8fbff;
|
||||||
|
--ink: #1e2a3b;
|
||||||
|
--muted: #5a6e87;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--line: rgba(30, 42, 59, 0.12);
|
||||||
|
--accent: #0a7f68;
|
||||||
|
--accent-soft: #e5fff7;
|
||||||
|
--danger: #b9382f;
|
||||||
|
--danger-soft: #ffe9e7;
|
||||||
|
--shadow: 0 24px 52px rgba(30, 42, 59, 0.13);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: "IBM Plex Sans", "Trebuchet MS", "Noto Sans", sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(1000px 550px at -5% -20%, rgba(102, 161, 255, 0.2), transparent 60%),
|
||||||
|
radial-gradient(700px 450px at 105% 110%, rgba(10, 127, 104, 0.17), transparent 65%),
|
||||||
|
linear-gradient(165deg, #f2f8ff, #fff9f1);
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
max-width: 1050px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(22px, 4vw, 32px);
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 11px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-color: rgba(10, 127, 104, 0.35);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 11px;
|
||||||
|
padding: 9px 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #3e4f6f, #2a3851);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages { display: grid; gap: 10px; }
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg.success {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid rgba(10, 127, 104, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg.error {
|
||||||
|
background: var(--danger-soft);
|
||||||
|
color: var(--danger);
|
||||||
|
border: 1px solid rgba(185, 56, 47, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: clamp(20px, 3vw, 28px);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 { margin: 0 0 6px; }
|
||||||
|
|
||||||
|
p { margin: 0; color: var(--muted); }
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: #fff;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #02a883);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table th,
|
||||||
|
.users-table td {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 10px 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border: 1px solid rgba(10, 127, 104, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.row { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="layout">
|
||||||
|
<section class="panel top">
|
||||||
|
<div>
|
||||||
|
<h1 class="brand">StFV Backup</h1>
|
||||||
|
<p class="sub">Angemeldet als {{ adminuser }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<nav>
|
||||||
|
<a class="nav-link {% if active_menu == 'dashboard' %}active{% endif %}" href="{{ url_for('dashboard') }}">Dashboard</a>
|
||||||
|
<a class="nav-link {% if active_menu == 'backups' %}active{% endif %}" href="{{ url_for('backup_new') }}">Neues Backup</a>
|
||||||
|
<a class="nav-link {% if active_menu == 'server' %}active{% endif %}" href="{{ url_for('server_settings') }}">Server Settings</a>
|
||||||
|
<a class="nav-link {% if active_menu == 'account' %}active{% endif %}" href="{{ url_for('account_settings') }}">Admin Konto</a>
|
||||||
|
<a class="nav-link {% if active_menu == 'users' %}active{% endif %}" href="{{ url_for('users') }}">Benutzer</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('logout') }}">
|
||||||
|
<button class="logout" type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<section class="panel content messages">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<p class="msg {{ category }}">{{ message }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<section class="panel content">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
{% extends "base_admin.html" %}
|
||||||
|
|
||||||
|
{% block title %}StFV Backup - Dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
.actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid rgba(30, 42, 59, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #1e2a3b;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
margin-right: 5px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: #f3f8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-danger {
|
||||||
|
color: #b9382f;
|
||||||
|
border-color: rgba(185, 56, 47, 0.3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h2>Eingerichtete Backups</h2>
|
||||||
|
<p>Hier siehst du alle Backup-Jobs mit Zeitplan, letztem Lauf und Größe.</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<a class="btn" href="{{ url_for('backup_new') }}">Neues Backup einrichten</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if backups %}
|
||||||
|
<table class="users-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Zeitplan</th>
|
||||||
|
<th>Letzter Lauf</th>
|
||||||
|
<th>Größe</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for backup in backups %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ backup.name }}</strong></td>
|
||||||
|
<td>{{ backup.schedule_mode }}</td>
|
||||||
|
<td>{{ backup.last_run_at | fmt_dt }}</td>
|
||||||
|
<td>{{ backup.last_size_bytes | fmt_bytes }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<form method="post" action="{{ url_for('backup_run', backup_id=backup.backup_id) }}" style="display:inline;">
|
||||||
|
<button class="icon-btn" type="submit" title="Manuell ausführen" aria-label="Manuell ausführen">▶</button>
|
||||||
|
</form>
|
||||||
|
<a class="icon-btn" href="{{ url_for('backup_edit', backup_id=backup.backup_id) }}" title="Bearbeiten" aria-label="Bearbeiten">✎</a>
|
||||||
|
<form method="post" action="{{ url_for('backup_delete', backup_id=backup.backup_id) }}" style="display:inline;" onsubmit="return confirm('Backup wirklich löschen?');">
|
||||||
|
<button class="icon-btn icon-btn-danger" type="submit" title="Löschen" aria-label="Löschen">🗑</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="card">
|
||||||
|
<p>Noch keine Backups eingerichtet. Lege jetzt den ersten Job an.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>StFV Backup - Login</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-1: #f7f9fc;
|
||||||
|
--bg-2: #d9e8ff;
|
||||||
|
--bg-3: #ffe7c9;
|
||||||
|
--text: #10223a;
|
||||||
|
--muted: #3f5875;
|
||||||
|
--surface: rgba(255, 255, 255, 0.85);
|
||||||
|
--accent: #007d66;
|
||||||
|
--accent-strong: #005f4e;
|
||||||
|
--danger: #c2352a;
|
||||||
|
--border: rgba(16, 34, 58, 0.12);
|
||||||
|
--shadow: 0 20px 55px rgba(16, 34, 58, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: "Fira Sans", "Avenir Next", "Noto Sans", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 700px at 10% -20%, var(--bg-2), transparent 65%),
|
||||||
|
radial-gradient(900px 600px at 90% 110%, var(--bg-3), transparent 60%),
|
||||||
|
linear-gradient(150deg, #eff5ff, var(--bg-1));
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
animation: fade-in 450ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
width: min(460px, 100%);
|
||||||
|
background: var(--surface);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: slide-up 500ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
padding: 26px 26px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(26px, 6vw, 34px);
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 18px 26px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text);
|
||||||
|
transition: border-color 180ms ease, box-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #5ca8ff;
|
||||||
|
box-shadow: 0 0 0 4px rgba(92, 168, 255, 0.17);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 13px 14px;
|
||||||
|
color: #ffffff;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #00a37f);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 120ms ease, filter 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
filter: brightness(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
background: linear-gradient(135deg, var(--accent-strong), #00795f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from { transform: translateY(20px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<header class="head">
|
||||||
|
<h1>StFV Backup</h1>
|
||||||
|
</header>
|
||||||
|
<section class="content">
|
||||||
|
{% if error_message %}
|
||||||
|
<p class="error">{{ error_message }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="{{ url_for('login') }}">
|
||||||
|
<div class="field">
|
||||||
|
<label for="username">Benutzername</label>
|
||||||
|
<input id="username" name="username" type="text" autocomplete="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Passwort</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends "base_admin.html" %}
|
||||||
|
|
||||||
|
{% block title %}StFV Backup - Server Settings{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Server Settings</h2>
|
||||||
|
<p>Diese Einstellungen gelten fuer den Flask-Webserver.</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<form method="post" action="{{ url_for('server_settings') }}">
|
||||||
|
<label for="ip">Server Host / IP</label>
|
||||||
|
<input id="ip" name="ip" type="text" value="{{ config.ip }}" required>
|
||||||
|
|
||||||
|
<label for="port">Server Port</label>
|
||||||
|
<input id="port" name="port" type="number" min="1" max="65535" value="{{ config.port }}" required>
|
||||||
|
|
||||||
|
<label for="debug">Debug Modus</label>
|
||||||
|
<select id="debug" name="debug">
|
||||||
|
<option value="false" {% if not config.debug %}selected{% endif %}>false</option>
|
||||||
|
<option value="true" {% if config.debug %}selected{% endif %}>true</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button class="btn" type="submit">Server Settings speichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{% extends "base_admin.html" %}
|
||||||
|
|
||||||
|
{% block title %}StFV Backup - Benutzer{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Benutzerverwaltung</h2>
|
||||||
|
<p>Lege neue Benutzer fuer den Browser-Login an und steuere Adminrechte.</p>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<article class="card">
|
||||||
|
<h3>Neuen Benutzer anlegen</h3>
|
||||||
|
<form method="post" action="{{ url_for('users') }}">
|
||||||
|
<label for="username">Benutzername</label>
|
||||||
|
<input id="username" name="username" type="text" required>
|
||||||
|
|
||||||
|
<label for="password">Passwort</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="new-password" required>
|
||||||
|
|
||||||
|
<label for="password_confirm">Passwort bestaetigen</label>
|
||||||
|
<input id="password_confirm" name="password_confirm" type="password" autocomplete="new-password" required>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<input name="is_admin" type="checkbox" style="width:auto; margin-right:8px;"> Als Admin anlegen
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="btn" type="submit">Benutzer erstellen</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<h3>Bestehende Benutzer</h3>
|
||||||
|
<table class="users-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Benutzername</th>
|
||||||
|
<th>Rolle</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in users %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ user.username }}</td>
|
||||||
|
<td>
|
||||||
|
{% if user.is_admin %}
|
||||||
|
<span class="badge">Admin</span>
|
||||||
|
{% else %}
|
||||||
|
Benutzer
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
class ConsoleUI:
|
||||||
|
RESET = "\033[0m"
|
||||||
|
BOLD = "\033[1m"
|
||||||
|
BLUE = "\033[38;5;39m"
|
||||||
|
GREEN = "\033[38;5;42m"
|
||||||
|
YELLOW = "\033[38;5;220m"
|
||||||
|
RED = "\033[38;5;196m"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def headline(cls, text: str) -> None:
|
||||||
|
print(f"{cls.BOLD}{cls.BLUE}{text}{cls.RESET}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def info(cls, text: str) -> None:
|
||||||
|
print(f"{cls.BLUE}[INFO]{cls.RESET} {text}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def success(cls, text: str) -> None:
|
||||||
|
print(f"{cls.GREEN}[OK]{cls.RESET} {text}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def warn(cls, text: str) -> None:
|
||||||
|
print(f"{cls.YELLOW}[WARN]{cls.RESET} {text}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def error(cls, text: str) -> None:
|
||||||
|
print(f"{cls.RED}[ERR]{cls.RESET} {text}")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class VirtualEnvManager:
|
||||||
|
def __init__(self, venv_dir: Path, entry_script: Path) -> None:
|
||||||
|
self.venv_dir = venv_dir
|
||||||
|
self.entry_script = entry_script
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def in_virtualenv() -> bool:
|
||||||
|
return (
|
||||||
|
hasattr(sys, "real_prefix")
|
||||||
|
or sys.prefix != getattr(sys, "base_prefix", sys.prefix)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _venv_python(self) -> Path:
|
||||||
|
if os.name == "nt":
|
||||||
|
return self.venv_dir / "Scripts" / "python.exe"
|
||||||
|
return self.venv_dir / "bin" / "python"
|
||||||
|
|
||||||
|
def ensure_and_reexec_if_needed(self) -> None:
|
||||||
|
if self.in_virtualenv():
|
||||||
|
return
|
||||||
|
|
||||||
|
venv_python = self._venv_python()
|
||||||
|
if not venv_python.exists():
|
||||||
|
subprocess.check_call([sys.executable, "-m", "venv", str(self.venv_dir)])
|
||||||
|
|
||||||
|
os.execv(
|
||||||
|
str(venv_python),
|
||||||
|
[str(venv_python), str(self.entry_script.resolve()), *sys.argv[1:]],
|
||||||
|
)
|
||||||
@@ -0,0 +1,866 @@
|
|||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import Flask, flash, jsonify, redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
|
from app.config_model import AppConfig, BackupExecutionJob, BackupJob
|
||||||
|
from app.config_store import ConfigStore
|
||||||
|
from app.remote_targets import (
|
||||||
|
TargetConnection,
|
||||||
|
browse_smb_directories,
|
||||||
|
create_smb_directory,
|
||||||
|
list_smb_shares,
|
||||||
|
normalize_subpath,
|
||||||
|
test_connection,
|
||||||
|
)
|
||||||
|
from app.security import hash_password, verify_password
|
||||||
|
|
||||||
|
|
||||||
|
COMPRESSION_METHODS = [
|
||||||
|
"zip",
|
||||||
|
"tar.gz",
|
||||||
|
"tar.bz2",
|
||||||
|
"tar.xz",
|
||||||
|
"7z",
|
||||||
|
]
|
||||||
|
|
||||||
|
TARGET_PROTOCOLS = ["SMB", "LOCAL", "FTP", "SFTP"]
|
||||||
|
ENCRYPTION_MODES = ["none", "password-aes256"]
|
||||||
|
SCHEDULE_MODES = [
|
||||||
|
"daily",
|
||||||
|
"weekly",
|
||||||
|
"monthly",
|
||||||
|
"yearly",
|
||||||
|
"every_n_days",
|
||||||
|
"every_n_weeks",
|
||||||
|
"custom",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FlaskServer:
|
||||||
|
def __init__(self, config: AppConfig, store: ConfigStore) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.store = store
|
||||||
|
self._run_lock = threading.Lock()
|
||||||
|
self._active_runs: dict[int, dict] = {}
|
||||||
|
|
||||||
|
def _get_run_status(self, backup_id: int) -> dict:
|
||||||
|
with self._run_lock:
|
||||||
|
state = self._active_runs.get(backup_id)
|
||||||
|
if not state:
|
||||||
|
return {
|
||||||
|
"is_running": False,
|
||||||
|
"progress_percent": 0,
|
||||||
|
"progress_text": "Bereit",
|
||||||
|
"status_message": "Kein Lauf aktiv",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"is_running": bool(state.get("is_running", False)),
|
||||||
|
"progress_percent": int(state.get("progress_percent", 0)),
|
||||||
|
"progress_text": str(state.get("progress_text", "")),
|
||||||
|
"status_message": str(state.get("status_message", "")),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _set_run_state(self, backup_id: int, **values) -> None:
|
||||||
|
with self._run_lock:
|
||||||
|
state = self._active_runs.get(backup_id, {})
|
||||||
|
state.update(values)
|
||||||
|
self._active_runs[backup_id] = state
|
||||||
|
|
||||||
|
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=progress,
|
||||||
|
progress_text=text,
|
||||||
|
status_message=text,
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
time.sleep(0.7)
|
||||||
|
|
||||||
|
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.store.mark_backup_run(backup_id)
|
||||||
|
self._set_run_state(
|
||||||
|
backup_id,
|
||||||
|
is_running=False,
|
||||||
|
progress_percent=100,
|
||||||
|
progress_text="Fertig",
|
||||||
|
status_message="Backup-Lauf erfolgreich abgeschlossen.",
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._set_run_state(
|
||||||
|
backup_id,
|
||||||
|
is_running=False,
|
||||||
|
progress_percent=0,
|
||||||
|
progress_text="Fehler",
|
||||||
|
status_message=f"Lauf fehlgeschlagen: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_port(value: str) -> int | None:
|
||||||
|
try:
|
||||||
|
port = int(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if 1 <= port <= 65535:
|
||||||
|
return port
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_optional_port(value: str) -> int | None:
|
||||||
|
raw = value.strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
return FlaskServer._parse_port(raw)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_host_and_port(host_input: str) -> tuple[str, int | None, str | None]:
|
||||||
|
raw = host_input.strip()
|
||||||
|
if not raw:
|
||||||
|
return "", None, "Host/IP darf nicht leer sein."
|
||||||
|
|
||||||
|
# IPv6 in Klammern: [fe80::1]:445
|
||||||
|
if raw.startswith("["):
|
||||||
|
end = raw.find("]")
|
||||||
|
if end == -1:
|
||||||
|
return "", None, "IPv6-Host muss in [ ] geklammert sein."
|
||||||
|
host = raw[1:end].strip()
|
||||||
|
rest = raw[end + 1 :].strip()
|
||||||
|
if not rest:
|
||||||
|
return host, None, None
|
||||||
|
if not rest.startswith(":"):
|
||||||
|
return "", None, "Nach IPv6-Host ist nur optional :Port erlaubt."
|
||||||
|
port = FlaskServer._parse_port(rest[1:])
|
||||||
|
if port is None:
|
||||||
|
return "", None, "Port muss zwischen 1 und 65535 liegen."
|
||||||
|
return host, port, None
|
||||||
|
|
||||||
|
# IPv4/FQDN optional mit :port
|
||||||
|
if raw.count(":") == 1:
|
||||||
|
host_part, port_part = raw.rsplit(":", 1)
|
||||||
|
host_part = host_part.strip()
|
||||||
|
port_part = port_part.strip()
|
||||||
|
if port_part:
|
||||||
|
port = FlaskServer._parse_port(port_part)
|
||||||
|
if port is None:
|
||||||
|
return "", None, "Port muss zwischen 1 und 65535 liegen."
|
||||||
|
return host_part, port, None
|
||||||
|
|
||||||
|
return raw, None, None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _target_from_payload(payload: dict, prefix: str) -> tuple[TargetConnection | None, str | None]:
|
||||||
|
protocol = payload.get(f"{prefix}_protocol", "").strip().upper()
|
||||||
|
host_input = payload.get(f"{prefix}_host", "").strip()
|
||||||
|
host, parsed_port, host_error = FlaskServer._split_host_and_port(host_input)
|
||||||
|
share = payload.get(f"{prefix}_share", "").strip()
|
||||||
|
raw_subpath = payload.get(f"{prefix}_subpath", "")
|
||||||
|
subpath = normalize_subpath(raw_subpath)
|
||||||
|
username = payload.get(f"{prefix}_username", "").strip()
|
||||||
|
password = payload.get(f"{prefix}_password", "")
|
||||||
|
|
||||||
|
if protocol not in TARGET_PROTOCOLS:
|
||||||
|
return None, "Bitte ein gueltiges Protokoll auswaehlen."
|
||||||
|
if protocol != "LOCAL" and host_error:
|
||||||
|
return None, host_error
|
||||||
|
|
||||||
|
if protocol == "LOCAL":
|
||||||
|
if not raw_subpath.strip():
|
||||||
|
return None, "Fuer LOCAL muss ein gueltiger lokaler Pfad angegeben werden."
|
||||||
|
return (
|
||||||
|
TargetConnection(
|
||||||
|
protocol=protocol,
|
||||||
|
host="local",
|
||||||
|
port=None,
|
||||||
|
share="",
|
||||||
|
subpath=subpath,
|
||||||
|
username="",
|
||||||
|
password="",
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if protocol == "SMB":
|
||||||
|
if not all([host, share, username, password]):
|
||||||
|
return None, "SMB benoetigt Host, Share, Benutzer und Passwort."
|
||||||
|
return (
|
||||||
|
TargetConnection(
|
||||||
|
protocol=protocol,
|
||||||
|
host=host,
|
||||||
|
port=parsed_port,
|
||||||
|
share=share,
|
||||||
|
subpath=subpath,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if protocol in {"FTP", "SFTP"}:
|
||||||
|
if not all([host, username, password]):
|
||||||
|
return None, f"{protocol} benoetigt Host, Benutzer und Passwort."
|
||||||
|
return (
|
||||||
|
TargetConnection(
|
||||||
|
protocol=protocol,
|
||||||
|
host=host,
|
||||||
|
port=parsed_port,
|
||||||
|
share=share,
|
||||||
|
subpath=subpath,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None, "Unbekanntes Protokoll."
|
||||||
|
|
||||||
|
def create_app(self) -> Flask:
|
||||||
|
app = Flask(__name__, template_folder="templates")
|
||||||
|
app.secret_key = os.getenv("APP_SECRET_KEY", secrets.token_hex(32))
|
||||||
|
app.config.update(
|
||||||
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
|
SESSION_COOKIE_SAMESITE="Lax",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.template_filter("fmt_bytes")
|
||||||
|
def fmt_bytes(value):
|
||||||
|
if value is None:
|
||||||
|
return "Noch kein Lauf"
|
||||||
|
try:
|
||||||
|
size = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "Unbekannt"
|
||||||
|
|
||||||
|
units = ["B", "KB", "MB", "GB", "TB"]
|
||||||
|
unit_idx = 0
|
||||||
|
while size >= 1024 and unit_idx < len(units) - 1:
|
||||||
|
size /= 1024
|
||||||
|
unit_idx += 1
|
||||||
|
return f"{size:.2f} {units[unit_idx]}"
|
||||||
|
|
||||||
|
@app.template_filter("fmt_dt")
|
||||||
|
def fmt_dt(value):
|
||||||
|
if not value:
|
||||||
|
return "Noch kein Lauf"
|
||||||
|
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
|
||||||
|
|
||||||
|
def login_required(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapped(*args, **kwargs):
|
||||||
|
if session.get("is_authenticated") is not True:
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
def admin_required(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapped(*args, **kwargs):
|
||||||
|
if session.get("is_admin") is not True:
|
||||||
|
return redirect(url_for("dashboard"))
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index():
|
||||||
|
if session.get("is_authenticated") is True:
|
||||||
|
return redirect(url_for("dashboard"))
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
error_message = ""
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username", "")
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
|
||||||
|
user = self.store.get_user(username)
|
||||||
|
if user and verify_password(password, user.password_hash):
|
||||||
|
session["is_authenticated"] = True
|
||||||
|
session["is_admin"] = user.is_admin
|
||||||
|
session["adminuser"] = user.username
|
||||||
|
return redirect(url_for("dashboard"))
|
||||||
|
error_message = "Anmeldung fehlgeschlagen."
|
||||||
|
|
||||||
|
return render_template("login.html", error_message=error_message)
|
||||||
|
|
||||||
|
@app.get("/dashboard")
|
||||||
|
@login_required
|
||||||
|
def dashboard():
|
||||||
|
return render_template(
|
||||||
|
"dashboard.html",
|
||||||
|
adminuser=session.get("adminuser", "admin"),
|
||||||
|
backups=self.store.list_backups(),
|
||||||
|
active_menu="dashboard",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/backups/<int:backup_id>/run")
|
||||||
|
@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",
|
||||||
|
)
|
||||||
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
|
@app.route("/backups/<int:backup_id>/edit", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def backup_edit(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"))
|
||||||
|
|
||||||
|
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", "")
|
||||||
|
|
||||||
|
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 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")
|
||||||
|
return redirect(url_for("backup_edit", backup_id=backup_id))
|
||||||
|
assert schedule is not None
|
||||||
|
|
||||||
|
updated = self.store.update_backup_edit_data(
|
||||||
|
backup_id=backup_id,
|
||||||
|
name=name,
|
||||||
|
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"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not updated:
|
||||||
|
flash("Backup konnte nicht gespeichert werden (Name eventuell doppelt).", "error")
|
||||||
|
return redirect(url_for("backup_edit", backup_id=backup_id))
|
||||||
|
|
||||||
|
flash("Backup wurde aktualisiert.", "success")
|
||||||
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"backup_edit.html",
|
||||||
|
adminuser=session.get("adminuser", "admin"),
|
||||||
|
backup=backup,
|
||||||
|
compression_methods=COMPRESSION_METHODS,
|
||||||
|
encryption_modes=ENCRYPTION_MODES,
|
||||||
|
schedule_modes=SCHEDULE_MODES,
|
||||||
|
active_menu="dashboard",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/backups/<int:backup_id>/delete")
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def backup_delete(backup_id: int):
|
||||||
|
deleted = self.store.delete_backup(backup_id)
|
||||||
|
if not deleted:
|
||||||
|
flash("Backup nicht gefunden.", "error")
|
||||||
|
else:
|
||||||
|
flash("Backup wurde gelöscht.", "success")
|
||||||
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
|
@app.post("/api/target/test")
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def api_target_test():
|
||||||
|
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
|
||||||
|
ok, message = test_connection(target)
|
||||||
|
status = 200 if ok else 400
|
||||||
|
return jsonify({"ok": ok, "message": message}), status
|
||||||
|
|
||||||
|
@app.post("/api/target/browse-smb")
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def api_target_browse_smb():
|
||||||
|
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
|
||||||
|
if target is None or target.protocol != "SMB":
|
||||||
|
return jsonify({"ok": False, "message": "SMB-Browser nur mit SMB moeglich."}), 400
|
||||||
|
|
||||||
|
if not target.share:
|
||||||
|
return (
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"message": "Bitte zuerst eine SMB-Freigabe (Share) waehlen oder Shares laden.",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
|
||||||
|
ok, path_or_error, directories = browse_smb_directories(target)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"ok": False, "message": path_or_error}), 400
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"path": path_or_error,
|
||||||
|
"entries": directories,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/api/target/smb-mkdir")
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def api_target_smb_mkdir():
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
prefix = payload.get("prefix", "")
|
||||||
|
if prefix != "destination":
|
||||||
|
return jsonify({"ok": False, "message": "Ordnererstellung ist nur fuer Destination erlaubt."}), 400
|
||||||
|
|
||||||
|
target, error = self._target_from_payload(payload, prefix)
|
||||||
|
if error:
|
||||||
|
return jsonify({"ok": False, "message": error}), 400
|
||||||
|
if target is None or target.protocol != "SMB":
|
||||||
|
return jsonify({"ok": False, "message": "Ordnererstellung nur mit SMB moeglich."}), 400
|
||||||
|
|
||||||
|
folder_name = payload.get("folder_name", "")
|
||||||
|
base_path = payload.get(f"{prefix}_subpath", "/")
|
||||||
|
ok, result = create_smb_directory(target, base_path, folder_name)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"ok": False, "message": result}), 400
|
||||||
|
return jsonify({"ok": True, "path": result, "message": "Ordner wurde erstellt."})
|
||||||
|
|
||||||
|
@app.post("/api/target/smb-shares")
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def api_target_smb_shares():
|
||||||
|
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
|
||||||
|
|
||||||
|
protocol = payload.get(f"{prefix}_protocol", "").strip().upper()
|
||||||
|
if protocol != "SMB":
|
||||||
|
return jsonify({"ok": False, "message": "Share-Liste nur mit SMB moeglich."}), 400
|
||||||
|
|
||||||
|
host_input = payload.get(f"{prefix}_host", "").strip()
|
||||||
|
host, parsed_port, host_error = self._split_host_and_port(host_input)
|
||||||
|
if host_error:
|
||||||
|
return jsonify({"ok": False, "message": host_error}), 400
|
||||||
|
|
||||||
|
username = payload.get(f"{prefix}_username", "").strip()
|
||||||
|
password = payload.get(f"{prefix}_password", "")
|
||||||
|
if not all([host, username, password]):
|
||||||
|
return (
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"message": "Host, Benutzer und Passwort sind zum Laden der Shares erforderlich.",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
|
||||||
|
target = TargetConnection(
|
||||||
|
protocol="SMB",
|
||||||
|
host=host,
|
||||||
|
port=parsed_port,
|
||||||
|
share="",
|
||||||
|
subpath="/",
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
)
|
||||||
|
ok, message, shares = list_smb_shares(target)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"ok": False, "message": message}), 400
|
||||||
|
return jsonify({"ok": True, "message": message, "shares": shares})
|
||||||
|
|
||||||
|
@app.route("/backups/new", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def backup_new():
|
||||||
|
if request.method == "POST":
|
||||||
|
payload = request.form.to_dict(flat=True)
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
source_target, source_error = self._target_from_payload(payload, "source")
|
||||||
|
destination_target, destination_error = self._target_from_payload(payload, "destination")
|
||||||
|
|
||||||
|
source_entry_type = request.form.get("source_entry_type", "directory").strip().lower()
|
||||||
|
target_kind = request.form.get("target_kind", "folder").strip().lower()
|
||||||
|
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", "")
|
||||||
|
|
||||||
|
schedule, schedule_error = parse_schedule_fields(request.form)
|
||||||
|
if schedule_error:
|
||||||
|
flash(schedule_error, "error")
|
||||||
|
return redirect(url_for("backup_new"))
|
||||||
|
assert schedule is not None
|
||||||
|
|
||||||
|
required_values = [name, target_pattern]
|
||||||
|
if any(not item for item in required_values):
|
||||||
|
flash("Bitte alle Pflichtfelder ausfuellen.", "error")
|
||||||
|
return redirect(url_for("backup_new"))
|
||||||
|
|
||||||
|
if source_error:
|
||||||
|
flash(f"Source: {source_error}", "error")
|
||||||
|
return redirect(url_for("backup_new"))
|
||||||
|
if destination_error:
|
||||||
|
flash(f"Destination: {destination_error}", "error")
|
||||||
|
return redirect(url_for("backup_new"))
|
||||||
|
|
||||||
|
if compression_method not in COMPRESSION_METHODS:
|
||||||
|
flash("Ungueltige Kompressionsmethode.", "error")
|
||||||
|
return redirect(url_for("backup_new"))
|
||||||
|
|
||||||
|
if source_entry_type not in {"directory", "file"}:
|
||||||
|
flash("Ungueltiger Source-Typ (Datei/Ordner).", "error")
|
||||||
|
return redirect(url_for("backup_new"))
|
||||||
|
if target_kind not in {"folder", "file"}:
|
||||||
|
flash("Ungueltiger Target-Typ.", "error")
|
||||||
|
return redirect(url_for("backup_new"))
|
||||||
|
|
||||||
|
if encryption_mode not in ENCRYPTION_MODES:
|
||||||
|
flash("Ungueltiger Verschluesselungsmodus.", "error")
|
||||||
|
return redirect(url_for("backup_new"))
|
||||||
|
if encryption_mode == "none":
|
||||||
|
archive_password = ""
|
||||||
|
archive_password_confirm = ""
|
||||||
|
else:
|
||||||
|
if not archive_password:
|
||||||
|
flash("Bitte ein Archiv-Passwort setzen.", "error")
|
||||||
|
return redirect(url_for("backup_new"))
|
||||||
|
if archive_password != archive_password_confirm:
|
||||||
|
flash("Archiv-Passwort und Bestaetigung stimmen nicht ueberein.", "error")
|
||||||
|
return redirect(url_for("backup_new"))
|
||||||
|
|
||||||
|
assert source_target is not None
|
||||||
|
assert destination_target is not None
|
||||||
|
|
||||||
|
backup = BackupJob(
|
||||||
|
name=name,
|
||||||
|
source_protocol=source_target.protocol,
|
||||||
|
source_host=source_target.host,
|
||||||
|
source_port=source_target.port,
|
||||||
|
source_share=source_target.share,
|
||||||
|
source_subpath=source_target.subpath,
|
||||||
|
source_username=source_target.username,
|
||||||
|
source_password=source_target.password,
|
||||||
|
destination_protocol=destination_target.protocol,
|
||||||
|
destination_host=destination_target.host,
|
||||||
|
destination_port=destination_target.port,
|
||||||
|
destination_share=destination_target.share,
|
||||||
|
destination_subpath=destination_target.subpath,
|
||||||
|
destination_username=destination_target.username,
|
||||||
|
destination_password=destination_target.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"],
|
||||||
|
)
|
||||||
|
|
||||||
|
created = self.store.create_backup(backup)
|
||||||
|
if not created:
|
||||||
|
flash("Backup-Name existiert bereits.", "error")
|
||||||
|
return redirect(url_for("backup_new"))
|
||||||
|
|
||||||
|
flash("Backup wurde gespeichert.", "success")
|
||||||
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"backup_new.html",
|
||||||
|
adminuser=session.get("adminuser", "admin"),
|
||||||
|
compression_methods=COMPRESSION_METHODS,
|
||||||
|
encryption_modes=ENCRYPTION_MODES,
|
||||||
|
schedule_modes=SCHEDULE_MODES,
|
||||||
|
target_protocols=TARGET_PROTOCOLS,
|
||||||
|
active_menu="backups",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/settings/server", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def server_settings():
|
||||||
|
if request.method == "POST":
|
||||||
|
ip = request.form.get("ip", "").strip()
|
||||||
|
port = self._parse_port(request.form.get("port", ""))
|
||||||
|
debug = request.form.get("debug", "false").lower() == "true"
|
||||||
|
|
||||||
|
if not ip:
|
||||||
|
flash("IP darf nicht leer sein.", "error")
|
||||||
|
return redirect(url_for("server_settings"))
|
||||||
|
if port is None:
|
||||||
|
flash("Port muss zwischen 1 und 65535 liegen.", "error")
|
||||||
|
return redirect(url_for("server_settings"))
|
||||||
|
|
||||||
|
self.config = AppConfig(ip=ip, port=port, debug=debug)
|
||||||
|
self.store.save_config(self.config)
|
||||||
|
flash("Server-Settings gespeichert. Neustart des Services erforderlich.", "success")
|
||||||
|
return redirect(url_for("server_settings"))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"server_settings.html",
|
||||||
|
adminuser=session.get("adminuser", "admin"),
|
||||||
|
config=self.config,
|
||||||
|
active_menu="server",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/settings/account", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def account_settings():
|
||||||
|
current_username = session.get("adminuser", "")
|
||||||
|
if request.method == "POST":
|
||||||
|
new_username = request.form.get("new_username", "").strip()
|
||||||
|
current_password = request.form.get("current_password", "")
|
||||||
|
new_password = request.form.get("new_password", "")
|
||||||
|
confirm_password = request.form.get("confirm_password", "")
|
||||||
|
|
||||||
|
user = self.store.get_user(current_username)
|
||||||
|
if user is None:
|
||||||
|
flash("Benutzer wurde nicht gefunden.", "error")
|
||||||
|
return redirect(url_for("logout"))
|
||||||
|
|
||||||
|
if not verify_password(current_password, user.password_hash):
|
||||||
|
flash("Aktuelles Passwort ist falsch.", "error")
|
||||||
|
return redirect(url_for("account_settings"))
|
||||||
|
if not new_username:
|
||||||
|
flash("Neuer Benutzername darf nicht leer sein.", "error")
|
||||||
|
return redirect(url_for("account_settings"))
|
||||||
|
if not new_password:
|
||||||
|
flash("Neues Passwort darf nicht leer sein.", "error")
|
||||||
|
return redirect(url_for("account_settings"))
|
||||||
|
if new_password != confirm_password:
|
||||||
|
flash("Passwort-Bestaetigung stimmt nicht ueberein.", "error")
|
||||||
|
return redirect(url_for("account_settings"))
|
||||||
|
|
||||||
|
updated = self.store.update_user_credentials(
|
||||||
|
current_username=current_username,
|
||||||
|
new_username=new_username,
|
||||||
|
new_password_hash=hash_password(new_password),
|
||||||
|
)
|
||||||
|
if not updated:
|
||||||
|
flash("Benutzername existiert bereits.", "error")
|
||||||
|
return redirect(url_for("account_settings"))
|
||||||
|
|
||||||
|
session["adminuser"] = new_username
|
||||||
|
flash("Deine Zugangsdaten wurden aktualisiert.", "success")
|
||||||
|
return redirect(url_for("account_settings"))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"account_settings.html",
|
||||||
|
adminuser=current_username,
|
||||||
|
active_menu="account",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/users", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def users():
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username", "").strip()
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
password_confirm = request.form.get("password_confirm", "")
|
||||||
|
is_admin = request.form.get("is_admin") == "on"
|
||||||
|
|
||||||
|
if not username:
|
||||||
|
flash("Benutzername darf nicht leer sein.", "error")
|
||||||
|
return redirect(url_for("users"))
|
||||||
|
if not password:
|
||||||
|
flash("Passwort darf nicht leer sein.", "error")
|
||||||
|
return redirect(url_for("users"))
|
||||||
|
if password != password_confirm:
|
||||||
|
flash("Passwort-Bestaetigung stimmt nicht ueberein.", "error")
|
||||||
|
return redirect(url_for("users"))
|
||||||
|
|
||||||
|
created = self.store.create_user(
|
||||||
|
username=username,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
is_admin=is_admin,
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
flash("Benutzername existiert bereits.", "error")
|
||||||
|
return redirect(url_for("users"))
|
||||||
|
|
||||||
|
flash("Neuer Benutzer wurde angelegt.", "success")
|
||||||
|
return redirect(url_for("users"))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"users.html",
|
||||||
|
adminuser=session.get("adminuser", "admin"),
|
||||||
|
users=self.store.list_users(),
|
||||||
|
active_menu="users",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/logout")
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
app = self.create_app()
|
||||||
|
app.run(host=self.config.ip, port=self.config.port, debug=self.config.debug)
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
IOmBIoG0lF8vAwVjd81SCyjYU3kMfhl9SfCKwf9qOh4=
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=StFV Backup Flask Service
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/home/osadmin/Nextcloud_Backup
|
||||||
|
ExecStart=/home/osadmin/Nextcloud_Backup/.venv/bin/python /home/osadmin/Nextcloud_Backup/main.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Projektabhaengigkeiten
|
||||||
|
Flask>=3.0,<4.0
|
||||||
|
cryptography>=43.0,<44.0
|
||||||
|
pysmb>=1.2.10,<2.0
|
||||||
|
paramiko>=3.4,<4.0
|
||||||
|
smbprotocol>=1.13.0,<2.0
|
||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SERVICE_NAME="nextcloud-backup.service"
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
USER_SYSTEMD_DIR="${HOME}/.config/systemd/user"
|
||||||
|
TARGET_SERVICE_FILE="${USER_SYSTEMD_DIR}/${SERVICE_NAME}"
|
||||||
|
SOURCE_SERVICE_FILE="${PROJECT_DIR}/deploy/systemd/${SERVICE_NAME}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: ./scripts/service.sh <command>
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
install Install service for current user and reload daemon
|
||||||
|
start Start service
|
||||||
|
stop Stop service
|
||||||
|
restart Restart service
|
||||||
|
status Show service status
|
||||||
|
logs Follow service logs
|
||||||
|
enable Enable autostart on login
|
||||||
|
disable Disable autostart on login
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_venv() {
|
||||||
|
if [[ ! -x "${PROJECT_DIR}/.venv/bin/python" ]]; then
|
||||||
|
echo "[INFO] Erzeuge .venv..."
|
||||||
|
python3 -m venv "${PROJECT_DIR}/.venv"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[INFO] Installiere/aktualisiere Abhaengigkeiten..."
|
||||||
|
"${PROJECT_DIR}/.venv/bin/python" -m pip install -r "${PROJECT_DIR}/requirements.txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_service() {
|
||||||
|
mkdir -p "${USER_SYSTEMD_DIR}"
|
||||||
|
cp "${SOURCE_SERVICE_FILE}" "${TARGET_SERVICE_FILE}"
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
echo "[OK] Service installiert: ${TARGET_SERVICE_FILE}"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
install)
|
||||||
|
ensure_venv
|
||||||
|
install_service
|
||||||
|
;;
|
||||||
|
start)
|
||||||
|
systemctl --user start "${SERVICE_NAME}"
|
||||||
|
echo "[OK] Service gestartet"
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
systemctl --user stop "${SERVICE_NAME}"
|
||||||
|
echo "[OK] Service gestoppt"
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
systemctl --user restart "${SERVICE_NAME}"
|
||||||
|
echo "[OK] Service neugestartet"
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
systemctl --user status "${SERVICE_NAME}" --no-pager
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
journalctl --user -u "${SERVICE_NAME}" -f
|
||||||
|
;;
|
||||||
|
enable)
|
||||||
|
systemctl --user enable "${SERVICE_NAME}"
|
||||||
|
echo "[OK] Autostart aktiviert"
|
||||||
|
;;
|
||||||
|
disable)
|
||||||
|
systemctl --user disable "${SERVICE_NAME}"
|
||||||
|
echo "[OK] Autostart deaktiviert"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Reference in New Issue
Block a user