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
+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)