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