import os import posixpath import secrets import shutil import tarfile import tempfile import threading from datetime import datetime from functools import wraps from pathlib import Path import zipfile from flask import Flask, flash, jsonify, redirect, render_template, request, session, url_for import smbclient try: import py7zr except Exception: # noqa: BLE001 py7zr = None from app.config_model import AppConfig, BackupExecutionJob, BackupJob from app.config_store import ConfigStore from app.remote_targets import ( TargetConnection, browse_ftp_directories, browse_sftp_directories, browse_smb_directories, create_smb_directory, list_smb_shares, normalize_subpath, test_connection, ) from app.security import hash_password, verify_password COMPRESSION_METHODS = [ "none", "zip", "tar.gz", "tar.bz2", "tar.xz", "7z", ] TARGET_PROTOCOLS = ["SMB", "LOCAL", "FTP", "SFTP"] ENCRYPTION_MODES = ["none", "password-aes256"] BACKUP_MODES = ["full", "sync", "clone"] 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 _build_runtime_meta(self, backup_id: int) -> dict: meta = self.store.get_backup_runtime_meta(backup_id) if meta is None: return { "last_run_at": None, "last_size_bytes": None, } return meta def _start_backup_run(self, backup_id: int) -> tuple[bool, str]: with self._run_lock: existing = self._active_runs.get(backup_id) if existing and bool(existing.get("is_running", False)): return False, "Backup-Lauf ist bereits aktiv." job = self.store.get_backup_execution_job(backup_id) if job is None: return False, "Backup nicht gefunden." stop_event = threading.Event() thread = threading.Thread( target=self._run_backup_job, args=(job, stop_event), daemon=True, name=f"backup-run-{backup_id}", ) self._set_run_state( backup_id, is_running=True, progress_percent=2, progress_text="Startet...", status_message="Manueller Lauf wird gestartet...", stop_event=stop_event, thread=thread, ) thread.start() return True, f"Manueller Lauf für '{job.name}' wurde gestartet." def _stop_backup_run(self, backup_id: int) -> tuple[bool, str]: with self._run_lock: state = self._active_runs.get(backup_id) if not state or not bool(state.get("is_running", False)): return False, "Kein aktiver Lauf zum Abbrechen." stop_event = state.get("stop_event") if stop_event is None or not hasattr(stop_event, "set"): return False, "Abbruch konnte nicht angefordert werden." stop_event.set() state["progress_text"] = "Stoppt..." state["status_message"] = "Abbruch wurde angefordert..." self._active_runs[backup_id] = state return True, "Abbruch wurde angefordert." @staticmethod def _replace_target_tokens(pattern: str) -> str: now = datetime.now() value = pattern value = value.replace("{date}", now.strftime("%Y-%m-%d")) value = value.replace("{date_de}", now.strftime("%d.%m.%Y")) value = value.replace("{date_compact}", now.strftime("%Y%m%d")) value = value.replace("{time}", now.strftime("%H-%M-%S")) value = value.replace("{time_short}", now.strftime("%H%M")) value = value.replace("{datetime}", now.strftime("%Y-%m-%d_%H-%M-%S")) return value @staticmethod def _join_subpath(base: str, child: str) -> str: normalized_base = normalize_subpath(base).rstrip("/") clean_child = child.strip("/") if not clean_child: return normalize_subpath(base) if normalized_base in {"", "/"}: return f"/{clean_child}" return f"{normalized_base}/{clean_child}" @staticmethod def _smb_unc_path(host: str, share: str, subpath: str) -> str: clean_share = share.strip().strip("\\/") clean_subpath = normalize_subpath(subpath).strip("/") if clean_subpath: return f"\\\\{host}\\{clean_share}\\{clean_subpath.replace('/', '\\\\')}" return f"\\\\{host}\\{clean_share}" def _ensure_smb_dir(self, host: str, share: str, subpath: str) -> None: parts = [p for p in normalize_subpath(subpath).strip("/").split("/") if p] current = "/" for part in parts: current = self._join_subpath(current, part) unc = self._smb_unc_path(host, share, current) try: smbclient.mkdir(unc) except Exception: # noqa: BLE001 # Zielordner kann bereits existieren. pass def _collect_source_files(self, job: BackupExecutionJob) -> list[tuple[str, str]]: source_protocol = job.source_protocol.upper() files: list[tuple[str, str]] = [] if source_protocol == "LOCAL": source_path = Path(job.source_subpath).expanduser() # Auto-detect file vs directory regardless of source_entry_type if source_path.is_file(): files.append((source_path.name, str(source_path))) return files if not source_path.is_dir(): raise RuntimeError(f"Source-Pfad nicht gefunden: {source_path}") for entry in source_path.rglob("*"): if entry.is_file(): rel = entry.relative_to(source_path).as_posix() files.append((rel, str(entry))) return files if source_protocol == "SMB": smbclient.register_session( server=job.source_host, username=job.source_username, password=job.source_password, port=job.source_port or 445, ) source_subpath = normalize_subpath(job.source_subpath) if job.source_entry_type == "file": basename = posixpath.basename(source_subpath.rstrip("/")) if not basename: raise RuntimeError("SMB Source-Datei ist ungültig.") files.append((basename, source_subpath)) return files def walk(current_subpath: str, prefix: str = "") -> None: unc = self._smb_unc_path(job.source_host, job.source_share, current_subpath) for item in smbclient.scandir(unc): if item.name in {".", ".."}: continue child_subpath = self._join_subpath(current_subpath, item.name) child_rel = f"{prefix}/{item.name}" if prefix else item.name if item.is_dir(): walk(child_subpath, child_rel) else: files.append((child_rel, child_subpath)) walk(source_subpath) return files if source_protocol in {"FTP", "SFTP"}: return self._collect_source_files_ftp(job) def _collect_source_files_ftp(self, job: BackupExecutionJob) -> list[tuple[str, str]]: """Returns list of (relative_path, ftp_path) pairs from an FTP source.""" from ftplib import FTP as _FTP ftp = _FTP() files: list[tuple[str, str]] = [] try: ftp.connect(job.source_host, job.source_port or 21, timeout=30) ftp.login(job.source_username, job.source_password) subpath = normalize_subpath(job.source_subpath) if job.source_entry_type == "file": basename = posixpath.basename(subpath.rstrip("/")) or posixpath.basename(subpath) if not basename: raise RuntimeError("FTP Source-Datei ist ungültig.") files.append((basename, subpath)) return files def ftp_walk(path: str, prefix: str = "") -> None: raw: list[str] = [] try: ftp.retrlines(f"LIST {path}", raw.append) except Exception: # noqa: BLE001 return for line in raw: parts = line.split(None, 8) if len(parts) < 9: continue name = parts[8].strip() if name in (".", ".."): continue is_dir = parts[0].startswith("d") child_path = f"{path.rstrip('/')}/{name}" child_rel = f"{prefix}/{name}" if prefix else name if is_dir: ftp_walk(child_path, child_rel) else: files.append((child_rel, child_path)) ftp_walk(subpath) finally: try: ftp.quit() except Exception: # noqa: BLE001 pass return files def _build_destination_spec(self, job: BackupExecutionJob, file_path: str, destination_protocol: str) -> str: if destination_protocol == "LOCAL": return file_path if destination_protocol in {"FTP", "SFTP"}: host_port = f"{job.destination_host}:{job.destination_port}" if job.destination_port else job.destination_host return f"{host_port}|{job.destination_username}|{job.destination_password}|{file_path}" return "|".join([job.destination_host, job.destination_share, file_path]) def _build_source_spec(self, job: BackupExecutionJob, file_path: str, source_protocol: str) -> str: if source_protocol == "LOCAL": return file_path if source_protocol in {"FTP", "SFTP"}: host_port = f"{job.source_host}:{job.source_port}" if job.source_port else job.source_host return f"{host_port}|{job.source_username}|{job.source_password}|{file_path}" # SMB return "|".join([job.source_host, job.source_share, file_path]) def _copy_file_stream( self, source_protocol: str, source_location: str, destination_protocol: str, destination_location: str, stop_event: threading.Event, ) -> int: total_bytes = 0 if source_protocol == "LOCAL": source_handle = open(source_location, "rb") elif source_protocol in {"FTP", "SFTP"}: # FTP/SFTP: download file to an in-memory buffer first import io from ftplib import FTP as _FTP buf = io.BytesIO() ftp_dl = _FTP() # source_location is the FTP path; connection info comes from the job context # We store it as "host:port|username|password|path" for FTP sources parts = source_location.split("|", 3) if len(parts) == 4: ftp_host_port, ftp_user, ftp_pass, ftp_path = parts _h, _p = (ftp_host_port.rsplit(":", 1) + [None])[:2] ftp_dl.connect(_h, int(_p) if _p else 21, timeout=60) ftp_dl.login(ftp_user, ftp_pass) ftp_dl.retrbinary(f"RETR {ftp_path}", buf.write) ftp_dl.quit() buf.seek(0) source_handle = buf else: raise RuntimeError(f"Ungültiger FTP-Source-Pfad: {source_location}") else: source_handle = smbclient.open_file( self._smb_unc_path(*source_location.split("|", 2)), mode="rb", ) if destination_protocol == "LOCAL": destination_handle = open(destination_location, "wb") elif destination_protocol in {"FTP", "SFTP"}: import io as _io from ftplib import FTP as _FTP parts = destination_location.split("|", 3) if len(parts) != 4: raise RuntimeError(f"Ungültiger FTP-Ziel-Pfad: {destination_location}") ftp_host_port, ftp_user, ftp_pass, ftp_path = parts _h, _p = (ftp_host_port.rsplit(":", 1) + [None])[:2] ftp_dest = _FTP() try: ftp_dest.connect(_h, int(_p) if _p else 21, timeout=60) ftp_dest.login(ftp_user, ftp_pass) # Ensure parent directories exist parent_dirs = [d for d in (ftp_path.rsplit("/", 1)[0] if "/" in ftp_path else "").split("/") if d] cur = "" for _d in parent_dirs: cur = f"{cur}/{_d}" try: ftp_dest.mkd(cur) except Exception: pass src_buf = _io.BytesIO(source_handle.read()) source_handle.close() ftp_dest.storbinary(f"STOR {ftp_path}", src_buf) return len(src_buf.getvalue()) except Exception as _exc: raise RuntimeError(f"FTP-Upload fehlgeschlagen: {_exc}") from _exc finally: try: ftp_dest.quit() except Exception: pass else: destination_handle = smbclient.open_file( self._smb_unc_path(*destination_location.split("|", 2)), mode="wb", ) try: while True: if stop_event.is_set(): raise RuntimeError("Lauf wurde abgebrochen.") chunk = source_handle.read(1024 * 1024) if not chunk: break destination_handle.write(chunk) total_bytes += len(chunk) finally: source_handle.close() destination_handle.close() return total_bytes @staticmethod def _compression_extension(compression_method: str) -> str: mapping = { "zip": ".zip", "tar.gz": ".tar.gz", "tar.bz2": ".tar.bz2", "tar.xz": ".tar.xz", "7z": ".7z", } return mapping.get(compression_method, "") @staticmethod def _ensure_archive_suffix(filename: str, compression_method: str) -> str: ext = FlaskServer._compression_extension(compression_method) if not ext: return filename lower_name = filename.lower() if lower_name.endswith(ext): return filename return f"{filename}{ext}" def _build_archive_file( self, job: BackupExecutionJob, source_files: list[tuple[str, str]], source_protocol: str, stop_event: threading.Event, ) -> tuple[Path, str, int]: method = job.compression_method if method not in {"zip", "tar.gz", "tar.bz2", "tar.xz", "7z"}: raise RuntimeError(f"Kompressionsmethode '{method}' wird nicht unterstützt.") if method == "7z" and py7zr is None: raise RuntimeError( "7z-Unterstützung ist nicht verfügbar. Bitte py7zr installieren und App neu starten." ) target_base = self._replace_target_tokens(job.target_pattern).strip() if not target_base: target_base = f"backup_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}" archive_name = self._ensure_archive_suffix(target_base, method) work_dir = Path(tempfile.mkdtemp(prefix="stfv-backup-")) staging_dir = work_dir / "staging" staging_dir.mkdir(parents=True, exist_ok=True) total_count = len(source_files) for index, (relative_path, source_path) in enumerate(source_files, start=1): if stop_event.is_set(): raise RuntimeError("Lauf wurde abgebrochen.") local_target = staging_dir / relative_path local_target.parent.mkdir(parents=True, exist_ok=True) source_spec = self._build_source_spec(job, source_path, source_protocol) self._copy_file_stream( source_protocol=source_protocol, source_location=source_spec, destination_protocol="LOCAL", destination_location=str(local_target), stop_event=stop_event, ) progress = 52 + int((index / max(1, total_count)) * 18) self._set_run_state( job.backup_id, is_running=True, progress_percent=min(progress, 70), progress_text="Sammle Dateien...", status_message=f"Datei {index}/{total_count} für Archiv vorbereitet.", ) archive_path = work_dir / archive_name self._set_run_state( job.backup_id, is_running=True, progress_percent=74, progress_text="Komprimiere...", status_message=f"Erzeuge {method}-Archiv...", ) if method == "zip": with zipfile.ZipFile(archive_path, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: for file_path in staging_dir.rglob("*"): if file_path.is_file(): arcname = file_path.relative_to(staging_dir).as_posix() zf.write(file_path, arcname=arcname) elif method in {"tar.gz", "tar.bz2", "tar.xz"}: mode_map = { "tar.gz": "w:gz", "tar.bz2": "w:bz2", "tar.xz": "w:xz", } with tarfile.open(archive_path, mode=mode_map[method]) as tf: for file_path in staging_dir.rglob("*"): if file_path.is_file(): arcname = file_path.relative_to(staging_dir).as_posix() tf.add(file_path, arcname=arcname) else: assert py7zr is not None with py7zr.SevenZipFile(archive_path, mode="w") as sz: for file_path in staging_dir.rglob("*"): if file_path.is_file(): arcname = file_path.relative_to(staging_dir).as_posix() sz.write(file_path, arcname=arcname) archive_bytes = archive_path.stat().st_size return archive_path, archive_name, archive_bytes def _list_destination_top_entries(self, job: BackupExecutionJob) -> list[tuple[str, float]]: """Returns [(name, mtime)] for direct children of destination_subpath.""" protocol = job.destination_protocol.upper() subpath = normalize_subpath(job.destination_subpath) if protocol == "LOCAL": result: list[tuple[str, float]] = [] root = Path(job.destination_subpath).expanduser() if not root.exists(): return result for entry in root.iterdir(): try: result.append((entry.name, entry.stat().st_mtime)) except Exception: pass return result if protocol == "SMB": result = [] unc = self._smb_unc_path(job.destination_host, job.destination_share, subpath) try: for item in smbclient.scandir(unc): if item.name in {".", ".."}: continue try: result.append((item.name, float(item.stat().st_mtime or 0))) except Exception: result.append((item.name, 0.0)) except Exception: pass return result if protocol == "FTP": from ftplib import FTP as _FTP from datetime import datetime as _dt result = [] ftp = _FTP() try: ftp.connect(job.destination_host, job.destination_port or 21, timeout=30) ftp.login(job.destination_username, job.destination_password) try: for name, facts in ftp.mlsd(subpath): if name in (".", ".."): continue mtime = 0.0 mts = facts.get("modify", "") if mts: try: mtime = _dt.strptime(mts[:14], "%Y%m%d%H%M%S").timestamp() except Exception: pass result.append((name, mtime)) except Exception: raw: list[str] = [] ftp.retrlines(f"LIST {subpath}", raw.append) for line in raw: parts = line.split(None, 8) if len(parts) >= 9 and parts[8].strip() not in (".", ".."): result.append((parts[8].strip(), 0.0)) ftp.quit() except Exception: pass return result if protocol == "SFTP": import paramiko result = [] client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) try: client.connect( job.destination_host, port=job.destination_port or 22, username=job.destination_username, password=job.destination_password, timeout=30, ) sftp = client.open_sftp() for attr in sftp.listdir_attr(subpath): if attr.filename not in (".", ".."): result.append((attr.filename, float(attr.st_mtime or 0))) sftp.close() except Exception: pass finally: client.close() return result return [] def _smb_rmdir_recursive(self, host: str, share: str, subpath: str) -> None: unc = self._smb_unc_path(host, share, subpath) try: for item in smbclient.scandir(unc): if item.name in {".", ".."}: continue child = self._join_subpath(subpath, item.name) if item.is_dir(): self._smb_rmdir_recursive(host, share, child) else: try: smbclient.remove(self._smb_unc_path(host, share, child)) except Exception: pass smbclient.rmdir(unc) except Exception: pass @staticmethod def _ftp_delete_recursive(ftp, path: str) -> None: try: ftp.delete(path) return except Exception: pass raw: list[str] = [] try: ftp.retrlines(f"LIST {path}", raw.append) except Exception: return for line in raw: parts = line.split(None, 8) if len(parts) < 9: continue name = parts[8].strip() if name in (".", ".."): continue child = f"{path.rstrip('/')}/{name}" if parts[0].startswith("d"): FlaskServer._ftp_delete_recursive(ftp, child) else: try: ftp.delete(child) except Exception: pass try: ftp.rmd(path) except Exception: pass @staticmethod def _sftp_delete_recursive(sftp, path: str) -> None: import stat as _stat try: attrs = sftp.listdir_attr(path) except Exception: try: sftp.remove(path) except Exception: pass return for attr in attrs: child = f"{path.rstrip('/')}/{attr.filename}" if _stat.S_ISDIR(attr.st_mode or 0): FlaskServer._sftp_delete_recursive(sftp, child) else: try: sftp.remove(child) except Exception: pass try: sftp.rmdir(path) except Exception: pass def _delete_destination_entry(self, job: BackupExecutionJob, entry_name: str) -> None: protocol = job.destination_protocol.upper() child_subpath = normalize_subpath(self._join_subpath(job.destination_subpath, entry_name)) try: if protocol == "LOCAL": target = Path(job.destination_subpath).expanduser() / entry_name if target.is_dir(): shutil.rmtree(target, ignore_errors=True) else: target.unlink(missing_ok=True) elif protocol == "SMB": unc = self._smb_unc_path(job.destination_host, job.destination_share, child_subpath) try: smbclient.remove(unc) except Exception: self._smb_rmdir_recursive(job.destination_host, job.destination_share, child_subpath) elif protocol == "FTP": from ftplib import FTP as _FTP ftp = _FTP() try: ftp.connect(job.destination_host, job.destination_port or 21, timeout=30) ftp.login(job.destination_username, job.destination_password) self._ftp_delete_recursive(ftp, child_subpath) ftp.quit() except Exception: pass elif protocol == "SFTP": import paramiko client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) try: client.connect( job.destination_host, port=job.destination_port or 22, username=job.destination_username, password=job.destination_password, timeout=30, ) sftp = client.open_sftp() self._sftp_delete_recursive(sftp, child_subpath) sftp.close() except Exception: pass finally: client.close() except Exception: pass def _apply_rotation(self, job: BackupExecutionJob) -> None: """Delete oldest destination entries if count exceeds rotation_keep.""" if job.rotation_keep <= 0 or job.backup_mode in {"sync", "clone"}: return entries = self._list_destination_top_entries(job) if len(entries) <= job.rotation_keep: return entries.sort(key=lambda x: x[1]) # oldest first for name, _ in entries[:len(entries) - job.rotation_keep]: self._delete_destination_entry(job, name) # ------------------------------------------------------------------ # # Sync / Clone helpers # # ------------------------------------------------------------------ # def _build_file_tree_local(self, subpath: str) -> dict[str, tuple[int, float]]: result: dict[str, tuple[int, float]] = {} root = Path(subpath).expanduser() if not root.is_dir(): return result for entry in root.rglob("*"): if entry.is_file(): st = entry.stat() result[entry.relative_to(root).as_posix()] = (st.st_size, st.st_mtime) return result def _build_file_tree_smb(self, host: str, share: str, subpath: str) -> dict[str, tuple[int, float]]: result: dict[str, tuple[int, float]] = {} def walk(current: str, prefix: str) -> None: unc = self._smb_unc_path(host, share, current) try: entries = list(smbclient.scandir(unc)) except Exception: return for item in entries: if item.name in {".", ".."}: continue child = self._join_subpath(current, item.name) rel = f"{prefix}/{item.name}" if prefix else item.name if item.is_dir(): walk(child, rel) else: try: st = item.stat() result[rel] = (st.st_size, float(st.st_mtime or 0)) except Exception: result[rel] = (0, 0.0) walk(normalize_subpath(subpath), "") return result def _build_file_tree_ftp( self, host: str, port: int | None, username: str, password: str, subpath: str ) -> dict[str, tuple[int, float]]: from ftplib import FTP as _FTP from datetime import datetime as _dt result: dict[str, tuple[int, float]] = {} ftp = _FTP() try: ftp.connect(host, port or 21, timeout=30) ftp.login(username, password) def walk(path: str, prefix: str) -> None: # Prefer MLSD (reliable size + mtime) over LIST try: entries = list(ftp.mlsd(path)) for name, facts in entries: if name in (".", ".."): continue child = f"{path.rstrip('/')}/{name}" rel = f"{prefix}/{name}" if prefix else name if facts.get("type") == "dir": walk(child, rel) else: size = int(facts.get("size", 0)) mtime = 0.0 mts = facts.get("modify", "") if mts: try: mtime = _dt.strptime(mts[:14], "%Y%m%d%H%M%S").timestamp() except Exception: pass result[rel] = (size, mtime) return except Exception: pass # Fallback: LIST raw: list[str] = [] try: ftp.retrlines(f"LIST {path}", raw.append) except Exception: return for line in raw: parts = line.split(None, 8) if len(parts) < 9: continue name = parts[8].strip() if name in (".", ".."): continue is_dir = parts[0].startswith("d") child = f"{path.rstrip('/')}/{name}" rel = f"{prefix}/{name}" if prefix else name if is_dir: walk(child, rel) else: size = int(parts[4]) if parts[4].isdigit() else 0 result[rel] = (size, 0.0) walk(normalize_subpath(subpath), "") finally: try: ftp.quit() except Exception: pass return result def _build_file_tree_sftp( self, host: str, port: int | None, username: str, password: str, subpath: str ) -> dict[str, tuple[int, float]]: import stat as _stat import paramiko result: dict[str, tuple[int, float]] = {} client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) try: client.connect(host, port=port or 22, username=username, password=password, timeout=30) sftp = client.open_sftp() def walk(path: str, prefix: str) -> None: try: attrs = sftp.listdir_attr(path) except Exception: return for attr in attrs: if attr.filename in (".", ".."): continue child = f"{path.rstrip('/')}/{attr.filename}" rel = f"{prefix}/{attr.filename}" if prefix else attr.filename if _stat.S_ISDIR(attr.st_mode or 0): walk(child, rel) else: result[rel] = (attr.st_size or 0, float(attr.st_mtime or 0)) walk(normalize_subpath(subpath), "") sftp.close() finally: client.close() return result def _build_file_tree(self, job: BackupExecutionJob, side: str) -> dict[str, tuple[int, float]]: if side == "source": protocol = job.source_protocol.upper() host, port, share = job.source_host, job.source_port, job.source_share subpath, username, password = job.source_subpath, job.source_username, job.source_password else: protocol = job.destination_protocol.upper() host, port, share = job.destination_host, job.destination_port, job.destination_share subpath, username, password = job.destination_subpath, job.destination_username, job.destination_password if protocol == "LOCAL": return self._build_file_tree_local(subpath) if protocol == "SMB": return self._build_file_tree_smb(host, share, subpath) if protocol == "FTP": return self._build_file_tree_ftp(host, port, username, password, subpath) if protocol == "SFTP": return self._build_file_tree_sftp(host, port, username, password, subpath) return {} def _delete_destination_file( self, protocol: str, job: BackupExecutionJob, rel_path: str ) -> None: subpath = self._join_subpath(job.destination_subpath, rel_path) try: if protocol == "LOCAL": Path(job.destination_subpath).expanduser().joinpath(rel_path).unlink(missing_ok=True) elif protocol == "SMB": unc = self._smb_unc_path(job.destination_host, job.destination_share, subpath) smbclient.remove(unc) elif protocol == "FTP": from ftplib import FTP as _FTP ftp = _FTP() ftp.connect(job.destination_host, job.destination_port or 21, timeout=30) ftp.login(job.destination_username, job.destination_password) ftp.delete(subpath) ftp.quit() elif protocol == "SFTP": import paramiko client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) client.connect( job.destination_host, port=job.destination_port or 22, username=job.destination_username, password=job.destination_password, timeout=30, ) client.open_sftp().remove(subpath) client.close() except Exception: pass def _sync_backup_payload(self, job: BackupExecutionJob, stop_event: threading.Event) -> tuple[int, int]: src_proto = job.source_protocol.upper() dst_proto = job.destination_protocol.upper() if src_proto == "SMB": smbclient.register_session( server=job.source_host, username=job.source_username, password=job.source_password, port=job.source_port or 445, ) if dst_proto == "SMB": smbclient.register_session( server=job.destination_host, username=job.destination_username, password=job.destination_password, port=job.destination_port or 445, ) self._set_run_state( job.backup_id, is_running=True, progress_percent=10, progress_text="Scanne Quelle...", status_message="Dateibaum der Quelle wird gelesen...", ) source_tree = self._build_file_tree(job, "source") self._set_run_state( job.backup_id, is_running=True, progress_percent=25, progress_text="Scanne Ziel...", status_message="Dateibaum des Ziels wird gelesen...", ) dest_tree = self._build_file_tree(job, "destination") # Determine changed/new files to_transfer = [ rel for rel, (src_sz, src_mt) in source_tree.items() if rel not in dest_tree or dest_tree[rel][0] != src_sz or abs(dest_tree[rel][1] - src_mt) > 2 ] # Clone only: files on destination that no longer exist on source to_delete = ( [rel for rel in dest_tree if rel not in source_tree] if job.backup_mode == "clone" else [] ) total_ops = len(to_transfer) + len(to_delete) if total_ops == 0: return 0, 0 copied_files = 0 total_bytes = 0 for index, rel_path in enumerate(to_transfer, start=1): if stop_event.is_set(): raise RuntimeError("Lauf wurde abgebrochen.") src_subpath = self._join_subpath(job.source_subpath, rel_path) dst_subpath = self._join_subpath(job.destination_subpath, rel_path) if dst_proto == "LOCAL": dst_abs = Path(job.destination_subpath).expanduser() / rel_path dst_abs.parent.mkdir(parents=True, exist_ok=True) dest_spec = str(dst_abs) elif dst_proto == "SMB": dst_parent = normalize_subpath(posixpath.dirname(dst_subpath)) self._ensure_smb_dir(job.destination_host, job.destination_share, dst_parent) dest_spec = self._build_destination_spec(job, dst_subpath, dst_proto) else: dest_spec = self._build_destination_spec(job, dst_subpath, dst_proto) src_spec = self._build_source_spec(job, src_subpath, src_proto) total_bytes += self._copy_file_stream( source_protocol=src_proto, source_location=src_spec, destination_protocol=dst_proto, destination_location=dest_spec, stop_event=stop_event, ) copied_files += 1 progress = 30 + int((index / max(1, total_ops)) * 60) self._set_run_state( job.backup_id, is_running=True, progress_percent=min(progress, 90), progress_text="Synchronisiere...", status_message=f"{index}/{len(to_transfer)} Dateien übertragen.", ) for rel_path in to_delete: if stop_event.is_set(): raise RuntimeError("Lauf wurde abgebrochen.") self._delete_destination_file(dst_proto, job, rel_path) return copied_files, total_bytes def _transfer_backup_payload(self, job: BackupExecutionJob, stop_event: threading.Event) -> tuple[int, int]: source_protocol = job.source_protocol.upper() destination_protocol = job.destination_protocol.upper() if source_protocol not in {"LOCAL", "SMB", "FTP", "SFTP"}: raise RuntimeError(f"Source-Protokoll '{source_protocol}' ist nicht unterstützt.") if destination_protocol not in {"LOCAL", "SMB", "FTP", "SFTP"}: raise RuntimeError(f"Destination-Protokoll '{destination_protocol}' ist nicht unterstützt.") if job.backup_mode in {"sync", "clone"}: return self._sync_backup_payload(job, stop_event) if destination_protocol == "SMB": smbclient.register_session( server=job.destination_host, username=job.destination_username, password=job.destination_password, port=job.destination_port or 445, ) source_files = self._collect_source_files(job) if not source_files: raise RuntimeError("Keine Dateien in der Quelle gefunden.") if job.compression_method != "none": archive_path, archive_name, archive_bytes = self._build_archive_file( job=job, source_files=source_files, source_protocol=source_protocol, stop_event=stop_event, ) destination_base = normalize_subpath(job.destination_subpath) # archive_name already encodes the target_pattern with resolved tokens + extension archive_rel = archive_name try: if destination_protocol == "LOCAL": destination_abs = (Path(job.destination_subpath).expanduser() / archive_rel).resolve() destination_abs.parent.mkdir(parents=True, exist_ok=True) destination_spec = str(destination_abs) elif destination_protocol in {"FTP", "SFTP"}: destination_subpath = self._join_subpath(destination_base, archive_rel) destination_spec = self._build_destination_spec(job, destination_subpath, destination_protocol) else: destination_subpath = self._join_subpath(destination_base, archive_rel) destination_parent = normalize_subpath(posixpath.dirname(destination_subpath)) self._ensure_smb_dir(job.destination_host, job.destination_share, destination_parent) destination_spec = "|".join([job.destination_host, job.destination_share, destination_subpath]) self._set_run_state( job.backup_id, is_running=True, progress_percent=90, progress_text="Übertrage Archiv...", status_message=f"Schreibe Archiv {archive_name} ins Ziel...", ) transferred = self._copy_file_stream( source_protocol="LOCAL", source_location=str(archive_path), destination_protocol=destination_protocol, destination_location=destination_spec, stop_event=stop_event, ) return 1, max(transferred, archive_bytes) finally: shutil.rmtree(archive_path.parent, ignore_errors=True) target_pattern = self._replace_target_tokens(job.target_pattern) destination_base = normalize_subpath(job.destination_subpath) if job.target_kind == "file" and len(source_files) != 1: raise RuntimeError("Target ist Datei, aber die Quelle enthält mehrere Dateien.") copied_files = 0 total_bytes = 0 total_count = len(source_files) for index, (relative_path, source_path) in enumerate(source_files, start=1): if stop_event.is_set(): raise RuntimeError("Lauf wurde abgebrochen.") if job.target_kind == "file": destination_rel = target_pattern else: destination_rel = f"{target_pattern}/{relative_path}" if target_pattern else relative_path if destination_protocol == "LOCAL": destination_abs = (Path(job.destination_subpath).expanduser() / destination_rel).resolve() destination_abs.parent.mkdir(parents=True, exist_ok=True) destination_spec = str(destination_abs) elif destination_protocol in {"FTP", "SFTP"}: destination_subpath = self._join_subpath(destination_base, destination_rel) destination_spec = self._build_destination_spec(job, destination_subpath, destination_protocol) else: destination_subpath = self._join_subpath(destination_base, destination_rel) destination_parent = normalize_subpath(posixpath.dirname(destination_subpath)) self._ensure_smb_dir(job.destination_host, job.destination_share, destination_parent) destination_spec = "|".join([job.destination_host, job.destination_share, destination_subpath]) source_spec = self._build_source_spec(job, source_path, source_protocol) total_bytes += self._copy_file_stream( source_protocol=source_protocol, source_location=source_spec, destination_protocol=destination_protocol, destination_location=destination_spec, stop_event=stop_event, ) copied_files += 1 progress = 50 + int((index / max(1, total_count)) * 45) self._set_run_state( job.backup_id, is_running=True, progress_percent=min(progress, 95), progress_text="Übertrage Daten...", status_message=f"Datei {index}/{total_count} wurde übertragen.", ) return copied_files, total_bytes def _run_backup_job(self, job: BackupExecutionJob, stop_event: threading.Event) -> None: backup_id = job.backup_id try: self._set_run_state( backup_id, is_running=True, progress_percent=8, progress_text="Initialisiere Lauf...", status_message="Initialisiere Lauf...", ) 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, ) self._set_run_state( backup_id, is_running=True, progress_percent=22, progress_text="Prüfe Quelle...", status_message="Prüfe Quelle...", ) 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 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, ) self._set_run_state( backup_id, is_running=True, progress_percent=45, progress_text="Prüfe Ziel...", status_message="Prüfe Ziel...", ) 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 self._set_run_state( backup_id, is_running=True, progress_percent=50, progress_text="Übertrage Daten...", status_message="Datenübertragung läuft...", ) copied_files, copied_bytes = self._transfer_backup_payload(job, stop_event) 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, copied_bytes) self._apply_rotation(job) self._set_run_state( backup_id, is_running=False, progress_percent=100, progress_text="Fertig", status_message=( f"Backup erfolgreich abgeschlossen: {copied_files} Dateien, " f"{copied_bytes} Bytes übertragen." ), ) 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_cron = form_data.get("schedule_cron", "").strip() return {"schedule_mode": "custom", "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): ok, message = self._start_backup_run(backup_id) flash(message, "success" if ok else "error") return redirect(url_for("dashboard")) @app.post("/api/backups//run") @login_required @admin_required def backup_run_api(backup_id: int): ok, message = self._start_backup_run(backup_id) status_code = 200 if not ok: status_code = 404 if "nicht gefunden" in message.lower() else 409 return jsonify( { "ok": ok, "message": message, "status": self._get_run_status(backup_id), "runtime": self._build_runtime_meta(backup_id), } ), status_code @app.post("/api/backups//stop") @login_required @admin_required def backup_stop_api(backup_id: int): ok, message = self._stop_backup_run(backup_id) status_code = 200 if ok else 409 return jsonify( { "ok": ok, "message": message, "status": self._get_run_status(backup_id), "runtime": self._build_runtime_meta(backup_id), } ), status_code @app.get("/api/backups//status") @login_required @admin_required def backup_status_api(backup_id: int): return jsonify( { "ok": True, "status": self._get_run_status(backup_id), "runtime": self._build_runtime_meta(backup_id), } ) @app.route("/backups//edit", methods=["GET", "POST"]) @login_required @admin_required def backup_edit(backup_id: int): backup = self.store.get_full_backup_for_edit(backup_id) if backup is None: flash("Backup nicht gefunden.", "error") return redirect(url_for("dashboard")) if request.method == "POST": payload = request.form.to_dict(flat=True) name = payload.get("name", "").strip() target_pattern = payload.get("target_pattern", "").strip() compression_method = payload.get("compression_method", "zip") encryption_mode = payload.get("encryption_mode", "none") source_entry_type = payload.get("source_entry_type", "directory").strip().lower() target_kind = payload.get("target_kind", "folder").strip().lower() # Source connection source_protocol = payload.get("source_protocol", "").strip().upper() if source_protocol == "LOCAL": source_host, source_port = "local", None else: source_host, source_port, h_err = self._split_host_and_port(payload.get("source_host", "")) if h_err: flash(f"Source: {h_err}", "error") return redirect(url_for("backup_edit", backup_id=backup_id)) source_share = payload.get("source_share", "").strip() source_subpath = normalize_subpath(payload.get("source_subpath", "/")) source_username = payload.get("source_username", "").strip() src_pwd_raw = payload.get("source_password", "") source_password: str | None = None if src_pwd_raw == "***" else src_pwd_raw # Destination connection destination_protocol = payload.get("destination_protocol", "").strip().upper() if destination_protocol == "LOCAL": destination_host, destination_port = "local", None else: destination_host, destination_port, d_err = self._split_host_and_port(payload.get("destination_host", "")) if d_err: flash(f"Destination: {d_err}", "error") return redirect(url_for("backup_edit", backup_id=backup_id)) destination_share = payload.get("destination_share", "").strip() destination_subpath = normalize_subpath(payload.get("destination_subpath", "/")) destination_username = payload.get("destination_username", "").strip() dst_pwd_raw = payload.get("destination_password", "") destination_password: str | None = None if dst_pwd_raw == "***" else dst_pwd_raw # Archive password arc_pwd_raw = payload.get("archive_password", "") arc_pwd_confirm = payload.get("archive_password_confirm", "") archive_password: str | None = None if arc_pwd_raw == "***" else arc_pwd_raw schedule, _ = parse_schedule_fields(request.form) backup_mode = payload.get("backup_mode", "full").strip().lower() try: rotation_keep = max(0, int(payload.get("rotation_keep", "0") or "0")) except ValueError: rotation_keep = 0 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 backup_mode not in BACKUP_MODES: flash("Ungültiger Backup-Modus.", "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 archive_password and archive_password != arc_pwd_confirm: flash("Archiv-Passwort und Bestätigung stimmen nicht überein.", "error") return redirect(url_for("backup_edit", backup_id=backup_id)) updated = self.store.update_full_backup( backup_id=backup_id, name=name, source_protocol=source_protocol, source_host=source_host, source_port=source_port, source_share=source_share, source_subpath=source_subpath, source_username=source_username, source_password=source_password, destination_protocol=destination_protocol, destination_host=destination_host, destination_port=destination_port, destination_share=destination_share, destination_subpath=destination_subpath, destination_username=destination_username, destination_password=destination_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_cron=schedule["schedule_cron"], backup_mode=backup_mode, rotation_keep=rotation_keep, ) if not updated: flash("Backup konnte nicht gespeichert werden.", "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, target_protocols=TARGET_PROTOCOLS, compression_methods=COMPRESSION_METHODS, encryption_modes=ENCRYPTION_MODES, backup_modes=BACKUP_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/local/mkdir") @login_required def api_local_mkdir(): import os payload = request.get_json(silent=True) or {} parent_path = payload.get("path", "").strip() folder_name = payload.get("folder_name", "").strip() if not parent_path or not folder_name: return jsonify({"ok": False, "message": "Pfad und Ordnername erforderlich."}) if "/" in folder_name or "\\" in folder_name or folder_name in (".", ".."): return jsonify({"ok": False, "message": "Ung\u00fcltiger Ordnername."}) new_path = os.path.join(os.path.abspath(parent_path), folder_name) try: os.makedirs(new_path, exist_ok=False) return jsonify({"ok": True, "path": new_path}) except FileExistsError: return jsonify({"ok": False, "message": "Ordner existiert bereits."}) except PermissionError: return jsonify({"ok": False, "message": "Kein Schreibrecht."}) except OSError as e: return jsonify({"ok": False, "message": str(e)}) @app.get("/api/local/browse") @login_required def api_local_browse(): import os raw_path = request.args.get("path", "/").strip() path = os.path.abspath(raw_path) if raw_path else "/" if not os.path.exists(path): return jsonify({"ok": False, "message": f"Pfad nicht gefunden: {path}"}) try: entries = [] parent = os.path.dirname(path) if path != "/" else "/" if path != parent: entries.append({"name": "..", "path": parent, "entry_type": "up"}) for item in sorted(os.scandir(path), key=lambda e: (not e.is_dir(), e.name.lower())): try: entries.append({ "name": item.name, "path": item.path, "entry_type": "directory" if item.is_dir() else "file", }) except (PermissionError, OSError): continue return jsonify({"ok": True, "path": path, "entries": entries}) except PermissionError: return jsonify({"ok": False, "message": "Kein Zugriff auf diesen Pfad."}) except OSError as e: return jsonify({"ok": False, "message": str(e)}) @app.post("/api/cron/preview") @login_required def api_cron_preview(): try: from croniter import croniter except ImportError: return jsonify({"ok": False, "message": "croniter nicht installiert."}), 500 payload = request.get_json(silent=True) or {} expr = payload.get("cron", "").strip() if not expr: return jsonify({"ok": False, "message": "Kein Ausdruck."}) try: it = croniter(expr, datetime.now()) next_dt = it.get_next(datetime) except Exception: return jsonify({"ok": False, "message": "Ungültiger CRON-Ausdruck."}) DE_DAYS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] DE_MONTHS = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"] next_run = ( f"{DE_DAYS[next_dt.weekday()]}, {next_dt.day:02d}. " f"{DE_MONTHS[next_dt.month - 1]} {next_dt.year} " f"um {next_dt.hour:02d}:{next_dt.minute:02d} Uhr" ) parts = expr.split() description = "Benutzerdefinierter Zeitplan" if len(parts) == 5: minute, hour, dom, month, dow = parts DE_DOW = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"] t = f"{int(hour):02d}:{int(minute):02d} Uhr" if hour.isdigit() and minute.isdigit() else "" if all(p in ("*", "*/1") for p in parts): description = "Jede Minute" elif hour == "*" and dom == "*" and month == "*" and dow == "*" and minute.isdigit(): description = f"Jede Stunde um :{int(minute):02d} Uhr" elif dom == "*" and month == "*" and dow == "*" and t: description = f"Täglich um {t}" elif dom == "*" and month == "*" and dow.isdigit() and t: description = f"Wöchentlich am {DE_DOW[int(dow) % 7]} um {t}" elif month == "*" and dow == "*" and dom.isdigit() and t: description = f"Monatlich am {dom}. um {t}" return jsonify({"ok": True, "description": description, "next_run": next_run}) @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") @login_required @admin_required def api_target_browse(): 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 protocol = target.protocol.upper() if protocol == "SMB": if not target.share: return jsonify({"ok": False, "message": "Bitte zuerst eine SMB-Freigabe (Share) angeben."}), 400 ok, path_or_err, entries = browse_smb_directories(target) elif protocol == "FTP": ok, path_or_err, entries = browse_ftp_directories(target) elif protocol == "SFTP": ok, path_or_err, entries = browse_sftp_directories(target) else: return jsonify({"ok": False, "message": f"Browser nicht verfügbar für: {protocol}"}), 400 if not ok: return jsonify({"ok": False, "message": path_or_err}), 400 return jsonify({"ok": True, "path": path_or_err, "entries": entries}) @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", "") backup_mode = request.form.get("backup_mode", "full").strip().lower() try: rotation_keep = max(0, int(request.form.get("rotation_keep", "0") or "0")) except ValueError: rotation_keep = 0 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 backup_mode not in BACKUP_MODES: flash("Ungueltiger Backup-Modus.", "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_cron=schedule["schedule_cron"], backup_mode=backup_mode, rotation_keep=rotation_keep, ) 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, backup_modes=BACKUP_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_scheduler(self) -> None: from datetime import timedelta try: from croniter import croniter as CronIter except ImportError: return fired: dict[int, str] = {} # backup_id → YYYYMMDDHHmm of the scheduled slot while not self._scheduler_stop.is_set(): now = datetime.now() # Sleep until the next minute boundary sleep_secs = 61 - now.second - now.microsecond / 1_000_000 if self._scheduler_stop.wait(sleep_secs): break fire_time = datetime.now() try: backups = self.store.list_backups() except Exception: continue for b in backups: if not b.schedule_cron: continue try: it = CronIter(b.schedule_cron, fire_time - timedelta(seconds=65)) next_dt = it.get_next(datetime) diff = (fire_time - next_dt).total_seconds() if not (0 <= diff <= 65): continue # Key by scheduled slot to prevent double-firing across minute boundaries sched_key = next_dt.strftime("%Y%m%d%H%M") if fired.get(b.backup_id) == sched_key: continue fired[b.backup_id] = sched_key self._start_backup_run(b.backup_id) except Exception: continue def run(self) -> None: import os app = self.create_app() # Start scheduler only in the actual serving process (not Werkzeug reloader watcher) if os.environ.get("WERKZEUG_RUN_MAIN") == "true" or not self.config.debug: self._scheduler_stop = threading.Event() threading.Thread( target=self._run_scheduler, daemon=True, name="backup-scheduler" ).start() app.run(host=self.config.ip, port=self.config.port, debug=self.config.debug)