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