Files
BackItUp/app/web_server.py
T

2557 lines
104 KiB
Python
Raw Normal View History

2026-07-29 17:36:22 +02:00
import os
import posixpath
2026-07-29 17:36:22 +02:00
import secrets
import shutil
import tarfile
import tempfile
2026-07-29 17:36:22 +02:00
import threading
2026-08-16 00:38:25 +02:00
import traceback
from datetime import datetime
2026-07-29 17:36:22 +02:00
from functools import wraps
from pathlib import Path
import zipfile
2026-07-29 17:36:22 +02:00
2026-08-16 00:38:25 +02:00
from flask import Flask, flash, g, got_request_exception, jsonify, redirect, render_template, request, session, url_for
import smbclient
try:
import py7zr
except Exception: # noqa: BLE001
py7zr = None
2026-07-29 17:36:22 +02:00
from app.config_model import AppConfig, BackupExecutionJob, BackupJob
from app.config_store import ConfigStore
2026-08-16 00:38:25 +02:00
from app.log_store import LogStore
2026-07-29 17:36:22 +02:00
from app.remote_targets import (
TargetConnection,
browse_ftp_directories,
browse_sftp_directories,
2026-07-29 17:36:22 +02:00
browse_smb_directories,
create_smb_directory,
list_smb_shares,
normalize_subpath,
test_connection,
)
from app.security import hash_password, verify_password
COMPRESSION_METHODS = [
"none",
2026-07-29 17:36:22 +02:00
"zip",
"tar.gz",
"tar.bz2",
"tar.xz",
"7z",
]
TARGET_PROTOCOLS = ["SMB", "LOCAL", "FTP", "SFTP"]
ENCRYPTION_MODES = ["none", "password-aes256"]
BACKUP_MODES = ["full", "sync", "clone"]
2026-07-29 17:36:22 +02:00
class FlaskServer:
2026-08-16 00:38:25 +02:00
def __init__(self, config: AppConfig, store: ConfigStore, log_store: LogStore) -> None:
2026-07-29 17:36:22 +02:00
self.config = config
self.store = store
2026-08-16 00:38:25 +02:00
self.log_store = log_store
2026-07-29 17:36:22 +02:00
self._run_lock = threading.Lock()
self._active_runs: dict[int, dict] = {}
2026-08-16 00:38:25 +02:00
def _log(
self,
*,
level: str,
category: str,
event: str,
message: str,
username: str | None = None,
backup_id: int | None = None,
details: dict | None = None,
status_code: int | None = None,
) -> None:
try:
request_path = request.path if request else None
request_method = request.method if request else None
except RuntimeError:
request_path = None
request_method = None
resolved_user = username
if not resolved_user:
try:
resolved_user = session.get("adminuser")
except RuntimeError:
resolved_user = None
self.log_store.write(
level=level,
category=category,
event=event,
message=message,
username=resolved_user,
backup_id=backup_id,
request_path=request_path,
request_method=request_method,
status_code=status_code,
details=details,
)
2026-07-29 17:36:22 +02:00
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)):
2026-08-16 00:38:25 +02:00
self._log(
level="WARNING",
category="backup",
event="run_start_rejected",
backup_id=backup_id,
message="Backup-Lauf konnte nicht gestartet werden, da bereits ein Lauf aktiv ist.",
)
return False, "Backup-Lauf ist bereits aktiv."
job = self.store.get_backup_execution_job(backup_id)
if job is None:
2026-08-16 00:38:25 +02:00
self._log(
level="ERROR",
category="backup",
event="run_start_failed",
backup_id=backup_id,
message="Backup-Lauf konnte nicht gestartet werden, da das Backup nicht gefunden wurde.",
)
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()
2026-08-16 00:38:25 +02:00
self._log(
level="INFO",
category="backup",
event="run_started",
backup_id=backup_id,
message=f"Manueller Backup-Lauf wurde gestartet: {job.name}",
)
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)):
2026-08-16 00:38:25 +02:00
self._log(
level="WARNING",
category="backup",
event="run_stop_rejected",
backup_id=backup_id,
message="Backup-Abbruch angefordert, aber kein Lauf war aktiv.",
)
return False, "Kein aktiver Lauf zum Abbrechen."
stop_event = state.get("stop_event")
if stop_event is None or not hasattr(stop_event, "set"):
2026-08-16 00:38:25 +02:00
self._log(
level="ERROR",
category="backup",
event="run_stop_failed",
backup_id=backup_id,
message="Backup-Abbruch konnte nicht angefordert werden (fehlendes stop_event).",
)
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
2026-08-16 00:38:25 +02:00
self._log(
level="INFO",
category="backup",
event="run_stop_requested",
backup_id=backup_id,
message="Abbruch eines laufenden Backups wurde angefordert.",
)
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)
2026-08-11 23:33:40 +02:00
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
2026-07-29 17:36:22 +02:00
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...",
)
2026-07-29 17:36:22 +02:00
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:
2026-08-16 00:38:25 +02:00
self._log(
level="ERROR",
category="backup",
event="source_check_failed",
backup_id=backup_id,
message=f"Quelle nicht erreichbar: {msg}",
)
2026-07-29 17:36:22 +02:00
self._set_run_state(
backup_id,
is_running=False,
progress_percent=0,
progress_text="Fehler",
status_message=f"Quelle nicht erreichbar: {msg}",
2026-07-29 17:36:22 +02:00
)
return
2026-07-29 17:36:22 +02:00
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:
2026-08-16 00:38:25 +02:00
self._log(
level="ERROR",
category="backup",
event="destination_check_failed",
backup_id=backup_id,
message=f"Ziel nicht erreichbar: {msg}",
)
self._set_run_state(
backup_id,
is_running=False,
progress_percent=0,
progress_text="Fehler",
status_message=f"Ziel nicht erreichbar: {msg}",
)
return
2026-07-29 17:36:22 +02:00
self._set_run_state(
backup_id,
is_running=True,
progress_percent=50,
progress_text="Übertrage Daten...",
status_message="Datenübertragung läuft...",
)
2026-07-29 17:36:22 +02:00
copied_files, copied_bytes = self._transfer_backup_payload(job, stop_event)
2026-07-29 17:36:22 +02:00
if stop_event.is_set():
2026-08-16 00:38:25 +02:00
self._log(
level="INFO",
category="backup",
event="run_stopped",
backup_id=backup_id,
message="Backup-Lauf wurde manuell gestoppt.",
)
2026-07-29 17:36:22 +02:00
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)
2026-08-16 00:38:25 +02:00
self._log(
level="INFO",
category="backup",
event="run_finished",
backup_id=backup_id,
message=(
f"Backup erfolgreich abgeschlossen: {copied_files} Dateien, {copied_bytes} Bytes."
),
details={"copied_files": copied_files, "copied_bytes": copied_bytes},
)
2026-07-29 17:36:22 +02:00
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."
),
2026-07-29 17:36:22 +02:00
)
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}",
)
2026-08-16 00:38:25 +02:00
self._log(
level="ERROR",
category="backup",
event="run_failed",
backup_id=backup_id,
message=f"Lauf fehlgeschlagen: {exc}",
details={"traceback": traceback.format_exc()},
)
2026-07-29 17:36:22 +02:00
@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",
)
2026-08-16 00:38:25 +02:00
@app.before_request
def _capture_request_start() -> None:
g._request_started_at = datetime.now()
@app.after_request
def _log_problematic_requests(response):
status = int(response.status_code)
if status >= 400:
elapsed_ms = None
started = getattr(g, "_request_started_at", None)
if isinstance(started, datetime):
elapsed_ms = int((datetime.now() - started).total_seconds() * 1000)
self._log(
level="WARNING" if status < 500 else "ERROR",
category="http",
event="request_completed",
message=f"HTTP {status} für {request.method} {request.path}",
details={"elapsed_ms": elapsed_ms},
status_code=status,
)
return response
def _handle_exception(sender, exception, **extra):
self._log(
level="ERROR",
category="http",
event="unhandled_exception",
message=f"Unhandled Exception: {exception}",
details={"traceback": traceback.format_exc()},
status_code=500,
)
got_request_exception.connect(_handle_exception, app)
2026-07-29 17:36:22 +02:00
@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
2026-07-29 17:36:22 +02:00
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
2026-08-16 00:38:25 +02:00
self._log(
level="INFO",
category="auth",
event="login_success",
message="Benutzer hat sich erfolgreich angemeldet.",
username=user.username,
)
2026-07-29 17:36:22 +02:00
return redirect(url_for("dashboard"))
2026-08-16 00:38:25 +02:00
self._log(
level="WARNING",
category="auth",
event="login_failed",
message="Anmeldung fehlgeschlagen.",
username=username.strip() or None,
)
2026-07-29 17:36:22 +02:00
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",
)
2026-08-16 00:38:25 +02:00
@app.get("/logs")
@login_required
@admin_required
def logs_view():
level = request.args.get("level", "").strip().upper()
category = request.args.get("category", "").strip()
search = request.args.get("q", "").strip()
try:
limit = int(request.args.get("limit", "200") or "200")
except ValueError:
limit = 200
entries = self.log_store.query_logs(
level=level or None,
category=category or None,
search=search or None,
limit=limit,
)
level_stats = self.log_store.count_by_level_last_hours(24)
categories = self.log_store.list_categories()
return render_template(
"logs.html",
adminuser=session.get("adminuser", "admin"),
logs=entries,
level_stats=level_stats,
categories=categories,
filters={
"level": level,
"category": category,
"q": search,
"limit": str(limit),
},
active_menu="logs",
detail_compactor=self.log_store.compact_details,
)
@app.get("/api/logs")
@login_required
@admin_required
def logs_api():
level = request.args.get("level", "").strip().upper()
category = request.args.get("category", "").strip()
search = request.args.get("q", "").strip()
try:
limit = int(request.args.get("limit", "200") or "200")
except ValueError:
limit = 200
entries = self.log_store.query_logs(
level=level or None,
category=category or None,
search=search or None,
limit=limit,
)
return jsonify({"ok": True, "entries": entries, "count": len(entries)})
@app.post("/logs/clear")
@login_required
@admin_required
def logs_clear():
removed = self.log_store.clear()
self._log(
level="WARNING",
category="audit",
event="logs_cleared",
message=f"Logdaten wurden bereinigt. Geloeschte Eintraege: {removed}",
)
flash(f"Logdaten wurden bereinigt ({removed} Einträge).", "success")
return redirect(url_for("logs_view"))
2026-07-29 17:36:22 +02:00
@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")
2026-07-29 17:36:22 +02:00
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),
}
)
2026-07-29 17:36:22 +02:00
@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)
2026-07-29 17:36:22 +02:00
if backup is None:
2026-08-16 00:38:25 +02:00
self._log(
level="WARNING",
category="backup",
event="edit_not_found",
backup_id=backup_id,
message="Backup konnte nicht bearbeitet werden, da es nicht gefunden wurde.",
)
2026-07-29 17:36:22 +02:00
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
2026-07-29 17:36:22 +02:00
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))
2026-07-29 17:36:22 +02:00
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")
2026-07-29 17:36:22 +02:00
return redirect(url_for("backup_edit", backup_id=backup_id))
updated = self.store.update_full_backup(
2026-07-29 17:36:22 +02:00
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,
2026-07-29 17:36:22 +02:00
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,
2026-07-29 17:36:22 +02:00
)
if not updated:
2026-08-16 00:38:25 +02:00
self._log(
level="ERROR",
category="backup",
event="edit_failed",
backup_id=backup_id,
message="Backup-Update konnte nicht gespeichert werden.",
)
flash("Backup konnte nicht gespeichert werden.", "error")
2026-07-29 17:36:22 +02:00
return redirect(url_for("backup_edit", backup_id=backup_id))
2026-08-16 00:38:25 +02:00
self._log(
level="INFO",
category="backup",
event="edit_saved",
backup_id=backup_id,
message=f"Backup wurde aktualisiert: {name}",
details={"backup_mode": backup_mode, "compression_method": compression_method},
)
2026-07-29 17:36:22 +02:00
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,
2026-07-29 17:36:22 +02:00
compression_methods=COMPRESSION_METHODS,
encryption_modes=ENCRYPTION_MODES,
backup_modes=BACKUP_MODES,
2026-07-29 17:36:22 +02:00
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:
2026-08-16 00:38:25 +02:00
self._log(
level="WARNING",
category="backup",
event="delete_not_found",
backup_id=backup_id,
message="Backup-Loeschung angefordert, aber Backup nicht gefunden.",
)
2026-07-29 17:36:22 +02:00
flash("Backup nicht gefunden.", "error")
else:
2026-08-16 00:38:25 +02:00
self._log(
level="WARNING",
category="backup",
event="deleted",
backup_id=backup_id,
message="Backup wurde geloescht.",
)
2026-07-29 17:36:22 +02:00
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"
2026-08-16 00:38:25 +02:00
def _format_weekday_text(value: str) -> str | None:
de_dow = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"]
short = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"]
token = value.strip()
if not token or token == "*":
return "jeden Tag"
if "/" in token:
return None
if "-" in token and "," not in token:
try:
start_raw, end_raw = token.split("-", 1)
start = int(start_raw) % 7
end = int(end_raw) % 7
except ValueError:
return None
if start == end:
return de_dow[start]
return f"{short[start]}-{short[end]}"
if "," in token:
labels: list[str] = []
for part in token.split(","):
part = part.strip()
if not part:
return None
try:
idx = int(part) % 7
except ValueError:
return None
labels.append(short[idx])
return ", ".join(labels)
try:
idx = int(token) % 7
except ValueError:
return None
return de_dow[idx]
if len(parts) == 5:
minute, hour, dom, month, dow = parts
t = f"{int(hour):02d}:{int(minute):02d} Uhr" if hour.isdigit() and minute.isdigit() else ""
2026-08-16 00:38:25 +02:00
dow_text = _format_weekday_text(dow)
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}"
2026-08-16 00:38:25 +02:00
elif dom == "*" and month == "*" and dow_text and t and dow != "*":
if "-" in dow_text or "," in dow_text:
description = f"Wochentags {dow_text} um {t}"
else:
description = f"Wöchentlich am {dow_text} 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})
2026-07-29 17:36:22 +02:00
@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})
2026-07-29 17:36:22 +02:00
@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
2026-07-29 17:36:22 +02:00
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"))
2026-07-29 17:36:22 +02:00
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,
2026-07-29 17:36:22 +02:00
)
created = self.store.create_backup(backup)
if not created:
2026-08-16 00:38:25 +02:00
self._log(
level="ERROR",
category="backup",
event="create_failed",
message=f"Backup konnte nicht erstellt werden, Name existiert bereits: {name}",
)
2026-07-29 17:36:22 +02:00
flash("Backup-Name existiert bereits.", "error")
return redirect(url_for("backup_new"))
2026-08-16 00:38:25 +02:00
self._log(
level="INFO",
category="backup",
event="created",
message=f"Backup wurde erstellt: {name}",
details={
"source_protocol": source_target.protocol,
"destination_protocol": destination_target.protocol,
"backup_mode": backup_mode,
},
)
2026-07-29 17:36:22 +02:00
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,
2026-07-29 17:36:22 +02:00
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)
2026-08-16 00:38:25 +02:00
self._log(
level="INFO",
category="config",
event="server_settings_saved",
message="Server-Einstellungen wurden aktualisiert.",
details={"ip": ip, "port": port, "debug": debug},
)
2026-07-29 17:36:22 +02:00
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"))
2026-08-16 00:38:25 +02:00
self._log(
level="INFO",
category="auth",
event="account_updated",
message="Account-Zugangsdaten wurden aktualisiert.",
username=new_username,
)
2026-07-29 17:36:22 +02:00
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"))
2026-08-16 00:38:25 +02:00
self._log(
level="INFO",
category="auth",
event="user_created",
message=f"Neuer Benutzer wurde erstellt: {username}",
details={"is_admin": is_admin},
)
2026-07-29 17:36:22 +02:00
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():
2026-08-16 00:38:25 +02:00
self._log(
level="INFO",
category="auth",
event="logout",
message="Benutzer wurde abgemeldet.",
)
2026-07-29 17:36:22 +02:00
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()
2026-08-16 00:38:25 +02:00
except Exception as exc:
self._log(
level="ERROR",
category="scheduler",
event="list_backups_failed",
message=f"Scheduler konnte Backup-Liste nicht laden: {exc}",
)
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
2026-08-16 00:38:25 +02:00
ok, start_message = self._start_backup_run(b.backup_id)
self._log(
level="INFO" if ok else "WARNING",
category="scheduler",
event="scheduled_run_triggered" if ok else "scheduled_run_not_started",
backup_id=b.backup_id,
message=(
f"Geplanter Lauf ausgeloest fuer Backup: {b.name}"
if ok
else f"Geplanter Lauf konnte nicht gestartet werden: {b.name} ({start_message})"
),
details={"scheduled_slot": sched_key, "cron": b.schedule_cron, "start_message": start_message},
)
except Exception as exc:
self._log(
level="ERROR",
category="scheduler",
event="cron_evaluation_failed",
backup_id=b.backup_id,
message=f"CRON-Auswertung fehlgeschlagen fuer Backup '{b.name}': {exc}",
details={"cron": b.schedule_cron},
)
continue
2026-07-29 17:36:22 +02:00
def run(self) -> None:
import os
2026-07-29 17:36:22 +02:00
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()
2026-08-16 00:38:25 +02:00
self._log(
level="INFO",
category="scheduler",
event="started",
message="Backup-Scheduler wurde gestartet.",
)
self._log(
level="INFO",
category="app",
event="startup",
message=f"Webserver startet auf {self.config.ip}:{self.config.port} (debug={self.config.debug}).",
details={"ip": self.config.ip, "port": self.config.port, "debug": self.config.debug},
)
2026-07-29 17:36:22 +02:00
app.run(host=self.config.ip, port=self.config.port, debug=self.config.debug)