Files
BackItUp/app/web_server.py
T
2026-08-11 23:33:40 +02:00

2155 lines
89 KiB
Python

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="backitup-"))
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/<int:backup_id>/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/<int:backup_id>/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/<int:backup_id>/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/<int:backup_id>/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/<int:backup_id>/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/<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/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)