v0.0.1 first commit

This commit is contained in:
2026-07-29 17:36:22 +02:00
parent 49bb6815a0
commit 2eee55384e
28 changed files with 3902 additions and 1 deletions
+100 -1
View File
@@ -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
```
+1
View File
@@ -0,0 +1 @@
"""Modulares Startsystem fuer die Anwendung."""
+58
View File
@@ -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()
+93
View File
@@ -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
+610
View File
@@ -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
+28
View File
@@ -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")
+53
View File
@@ -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
+23
View File
@@ -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"
+369
View File
@@ -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
+23
View File
@@ -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)
+72
View File
@@ -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
+31
View File
@@ -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 %}
+109
View File
@@ -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 %}
+714
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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 %}
+256
View File
@@ -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>
+82
View File
@@ -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">&#9654;</button>
</form>
<a class="icon-btn" href="{{ url_for('backup_edit', backup_id=backup.backup_id) }}" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</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">&#128465;</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 %}
+157
View File
@@ -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>
+26
View File
@@ -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 %}
+56
View File
@@ -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 %}
+27
View File
@@ -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}")
+35
View File
@@ -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:]],
)
+866
View File
@@ -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)
BIN
View File
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
IOmBIoG0lF8vAwVjd81SCyjYU3kMfhl9SfCKwf9qOh4=
+15
View File
@@ -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
+13
View File
@@ -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()
+6
View File
@@ -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
+78
View File
@@ -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