2026-07-29 17:36:22 +02:00
|
|
|
import os
|
2026-08-11 22:45:58 +02:00
|
|
|
import posixpath
|
2026-07-29 17:36:22 +02:00
|
|
|
import secrets
|
2026-08-11 22:45:58 +02:00
|
|
|
import shutil
|
|
|
|
|
import tarfile
|
|
|
|
|
import tempfile
|
2026-07-29 17:36:22 +02:00
|
|
|
import threading
|
2026-08-11 22:45:58 +02:00
|
|
|
from datetime import datetime
|
2026-07-29 17:36:22 +02:00
|
|
|
from functools import wraps
|
2026-08-11 22:45:58 +02:00
|
|
|
from pathlib import Path
|
|
|
|
|
import zipfile
|
2026-07-29 17:36:22 +02:00
|
|
|
|
|
|
|
|
from flask import Flask, flash, jsonify, redirect, render_template, request, session, url_for
|
2026-08-11 22:45:58 +02:00
|
|
|
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
|
|
|
|
|
from app.remote_targets import (
|
|
|
|
|
TargetConnection,
|
2026-08-11 22:45:58 +02:00
|
|
|
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 = [
|
2026-08-11 22:45:58 +02:00
|
|
|
"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"]
|
2026-08-11 22:45:58 +02:00
|
|
|
BACKUP_MODES = ["full", "sync", "clone"]
|
2026-07-29 17:36:22 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-08-11 22:45:58 +02:00
|
|
|
def _build_runtime_meta(self, backup_id: int) -> dict:
|
|
|
|
|
meta = self.store.get_backup_runtime_meta(backup_id)
|
|
|
|
|
if meta is None:
|
|
|
|
|
return {
|
|
|
|
|
"last_run_at": None,
|
|
|
|
|
"last_size_bytes": None,
|
|
|
|
|
}
|
|
|
|
|
return meta
|
|
|
|
|
|
|
|
|
|
def _start_backup_run(self, backup_id: int) -> tuple[bool, str]:
|
|
|
|
|
with self._run_lock:
|
|
|
|
|
existing = self._active_runs.get(backup_id)
|
|
|
|
|
if existing and bool(existing.get("is_running", False)):
|
|
|
|
|
return False, "Backup-Lauf ist bereits aktiv."
|
|
|
|
|
|
|
|
|
|
job = self.store.get_backup_execution_job(backup_id)
|
|
|
|
|
if job is None:
|
|
|
|
|
return False, "Backup nicht gefunden."
|
|
|
|
|
|
|
|
|
|
stop_event = threading.Event()
|
|
|
|
|
thread = threading.Thread(
|
|
|
|
|
target=self._run_backup_job,
|
|
|
|
|
args=(job, stop_event),
|
|
|
|
|
daemon=True,
|
|
|
|
|
name=f"backup-run-{backup_id}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self._set_run_state(
|
|
|
|
|
backup_id,
|
|
|
|
|
is_running=True,
|
|
|
|
|
progress_percent=2,
|
|
|
|
|
progress_text="Startet...",
|
|
|
|
|
status_message="Manueller Lauf wird gestartet...",
|
|
|
|
|
stop_event=stop_event,
|
|
|
|
|
thread=thread,
|
|
|
|
|
)
|
|
|
|
|
thread.start()
|
|
|
|
|
return True, f"Manueller Lauf für '{job.name}' wurde gestartet."
|
|
|
|
|
|
|
|
|
|
def _stop_backup_run(self, backup_id: int) -> tuple[bool, str]:
|
|
|
|
|
with self._run_lock:
|
|
|
|
|
state = self._active_runs.get(backup_id)
|
|
|
|
|
if not state or not bool(state.get("is_running", False)):
|
|
|
|
|
return False, "Kein aktiver Lauf zum Abbrechen."
|
|
|
|
|
|
|
|
|
|
stop_event = state.get("stop_event")
|
|
|
|
|
if stop_event is None or not hasattr(stop_event, "set"):
|
|
|
|
|
return False, "Abbruch konnte nicht angefordert werden."
|
|
|
|
|
|
|
|
|
|
stop_event.set()
|
|
|
|
|
state["progress_text"] = "Stoppt..."
|
|
|
|
|
state["status_message"] = "Abbruch wurde angefordert..."
|
|
|
|
|
self._active_runs[backup_id] = state
|
|
|
|
|
|
|
|
|
|
return True, "Abbruch wurde angefordert."
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _replace_target_tokens(pattern: str) -> str:
|
|
|
|
|
now = datetime.now()
|
|
|
|
|
value = pattern
|
|
|
|
|
value = value.replace("{date}", now.strftime("%Y-%m-%d"))
|
|
|
|
|
value = value.replace("{date_de}", now.strftime("%d.%m.%Y"))
|
|
|
|
|
value = value.replace("{date_compact}", now.strftime("%Y%m%d"))
|
|
|
|
|
value = value.replace("{time}", now.strftime("%H-%M-%S"))
|
|
|
|
|
value = value.replace("{time_short}", now.strftime("%H%M"))
|
|
|
|
|
value = value.replace("{datetime}", now.strftime("%Y-%m-%d_%H-%M-%S"))
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _join_subpath(base: str, child: str) -> str:
|
|
|
|
|
normalized_base = normalize_subpath(base).rstrip("/")
|
|
|
|
|
clean_child = child.strip("/")
|
|
|
|
|
if not clean_child:
|
|
|
|
|
return normalize_subpath(base)
|
|
|
|
|
if normalized_base in {"", "/"}:
|
|
|
|
|
return f"/{clean_child}"
|
|
|
|
|
return f"{normalized_base}/{clean_child}"
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _smb_unc_path(host: str, share: str, subpath: str) -> str:
|
|
|
|
|
clean_share = share.strip().strip("\\/")
|
|
|
|
|
clean_subpath = normalize_subpath(subpath).strip("/")
|
|
|
|
|
if clean_subpath:
|
|
|
|
|
return f"\\\\{host}\\{clean_share}\\{clean_subpath.replace('/', '\\\\')}"
|
|
|
|
|
return f"\\\\{host}\\{clean_share}"
|
|
|
|
|
|
|
|
|
|
def _ensure_smb_dir(self, host: str, share: str, subpath: str) -> None:
|
|
|
|
|
parts = [p for p in normalize_subpath(subpath).strip("/").split("/") if p]
|
|
|
|
|
current = "/"
|
|
|
|
|
for part in parts:
|
|
|
|
|
current = self._join_subpath(current, part)
|
|
|
|
|
unc = self._smb_unc_path(host, share, current)
|
|
|
|
|
try:
|
|
|
|
|
smbclient.mkdir(unc)
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
# Zielordner kann bereits existieren.
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _collect_source_files(self, job: BackupExecutionJob) -> list[tuple[str, str]]:
|
|
|
|
|
source_protocol = job.source_protocol.upper()
|
|
|
|
|
files: list[tuple[str, str]] = []
|
|
|
|
|
|
|
|
|
|
if source_protocol == "LOCAL":
|
|
|
|
|
source_path = Path(job.source_subpath).expanduser()
|
|
|
|
|
# Auto-detect file vs directory regardless of source_entry_type
|
|
|
|
|
if source_path.is_file():
|
|
|
|
|
files.append((source_path.name, str(source_path)))
|
|
|
|
|
return files
|
|
|
|
|
if not source_path.is_dir():
|
|
|
|
|
raise RuntimeError(f"Source-Pfad nicht gefunden: {source_path}")
|
|
|
|
|
|
|
|
|
|
for entry in source_path.rglob("*"):
|
|
|
|
|
if entry.is_file():
|
|
|
|
|
rel = entry.relative_to(source_path).as_posix()
|
|
|
|
|
files.append((rel, str(entry)))
|
|
|
|
|
return files
|
|
|
|
|
|
|
|
|
|
if source_protocol == "SMB":
|
|
|
|
|
smbclient.register_session(
|
|
|
|
|
server=job.source_host,
|
|
|
|
|
username=job.source_username,
|
|
|
|
|
password=job.source_password,
|
|
|
|
|
port=job.source_port or 445,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
source_subpath = normalize_subpath(job.source_subpath)
|
|
|
|
|
if job.source_entry_type == "file":
|
|
|
|
|
basename = posixpath.basename(source_subpath.rstrip("/"))
|
|
|
|
|
if not basename:
|
|
|
|
|
raise RuntimeError("SMB Source-Datei ist ungültig.")
|
|
|
|
|
files.append((basename, source_subpath))
|
|
|
|
|
return files
|
|
|
|
|
|
|
|
|
|
def walk(current_subpath: str, prefix: str = "") -> None:
|
|
|
|
|
unc = self._smb_unc_path(job.source_host, job.source_share, current_subpath)
|
|
|
|
|
for item in smbclient.scandir(unc):
|
|
|
|
|
if item.name in {".", ".."}:
|
|
|
|
|
continue
|
|
|
|
|
child_subpath = self._join_subpath(current_subpath, item.name)
|
|
|
|
|
child_rel = f"{prefix}/{item.name}" if prefix else item.name
|
|
|
|
|
if item.is_dir():
|
|
|
|
|
walk(child_subpath, child_rel)
|
|
|
|
|
else:
|
|
|
|
|
files.append((child_rel, child_subpath))
|
|
|
|
|
|
|
|
|
|
walk(source_subpath)
|
|
|
|
|
return files
|
|
|
|
|
|
|
|
|
|
if source_protocol in {"FTP", "SFTP"}:
|
|
|
|
|
return self._collect_source_files_ftp(job)
|
|
|
|
|
|
|
|
|
|
def _collect_source_files_ftp(self, job: BackupExecutionJob) -> list[tuple[str, str]]:
|
|
|
|
|
"""Returns list of (relative_path, ftp_path) pairs from an FTP source."""
|
|
|
|
|
from ftplib import FTP as _FTP
|
|
|
|
|
ftp = _FTP()
|
|
|
|
|
files: list[tuple[str, str]] = []
|
|
|
|
|
try:
|
|
|
|
|
ftp.connect(job.source_host, job.source_port or 21, timeout=30)
|
|
|
|
|
ftp.login(job.source_username, job.source_password)
|
|
|
|
|
subpath = normalize_subpath(job.source_subpath)
|
|
|
|
|
if job.source_entry_type == "file":
|
|
|
|
|
basename = posixpath.basename(subpath.rstrip("/")) or posixpath.basename(subpath)
|
|
|
|
|
if not basename:
|
|
|
|
|
raise RuntimeError("FTP Source-Datei ist ungültig.")
|
|
|
|
|
files.append((basename, subpath))
|
|
|
|
|
return files
|
|
|
|
|
|
|
|
|
|
def ftp_walk(path: str, prefix: str = "") -> None:
|
|
|
|
|
raw: list[str] = []
|
|
|
|
|
try:
|
|
|
|
|
ftp.retrlines(f"LIST {path}", raw.append)
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
return
|
|
|
|
|
for line in raw:
|
|
|
|
|
parts = line.split(None, 8)
|
|
|
|
|
if len(parts) < 9:
|
|
|
|
|
continue
|
|
|
|
|
name = parts[8].strip()
|
|
|
|
|
if name in (".", ".."):
|
|
|
|
|
continue
|
|
|
|
|
is_dir = parts[0].startswith("d")
|
|
|
|
|
child_path = f"{path.rstrip('/')}/{name}"
|
|
|
|
|
child_rel = f"{prefix}/{name}" if prefix else name
|
|
|
|
|
if is_dir:
|
|
|
|
|
ftp_walk(child_path, child_rel)
|
|
|
|
|
else:
|
|
|
|
|
files.append((child_rel, child_path))
|
|
|
|
|
|
|
|
|
|
ftp_walk(subpath)
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
ftp.quit()
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
pass
|
|
|
|
|
return files
|
|
|
|
|
|
|
|
|
|
def _build_destination_spec(self, job: BackupExecutionJob, file_path: str, destination_protocol: str) -> str:
|
|
|
|
|
if destination_protocol == "LOCAL":
|
|
|
|
|
return file_path
|
|
|
|
|
if destination_protocol in {"FTP", "SFTP"}:
|
|
|
|
|
host_port = f"{job.destination_host}:{job.destination_port}" if job.destination_port else job.destination_host
|
|
|
|
|
return f"{host_port}|{job.destination_username}|{job.destination_password}|{file_path}"
|
|
|
|
|
return "|".join([job.destination_host, job.destination_share, file_path])
|
|
|
|
|
|
|
|
|
|
def _build_source_spec(self, job: BackupExecutionJob, file_path: str, source_protocol: str) -> str:
|
|
|
|
|
if source_protocol == "LOCAL":
|
|
|
|
|
return file_path
|
|
|
|
|
if source_protocol in {"FTP", "SFTP"}:
|
|
|
|
|
host_port = f"{job.source_host}:{job.source_port}" if job.source_port else job.source_host
|
|
|
|
|
return f"{host_port}|{job.source_username}|{job.source_password}|{file_path}"
|
|
|
|
|
# SMB
|
|
|
|
|
return "|".join([job.source_host, job.source_share, file_path])
|
|
|
|
|
|
|
|
|
|
def _copy_file_stream(
|
|
|
|
|
self,
|
|
|
|
|
source_protocol: str,
|
|
|
|
|
source_location: str,
|
|
|
|
|
destination_protocol: str,
|
|
|
|
|
destination_location: str,
|
|
|
|
|
stop_event: threading.Event,
|
|
|
|
|
) -> int:
|
|
|
|
|
total_bytes = 0
|
|
|
|
|
|
|
|
|
|
if source_protocol == "LOCAL":
|
|
|
|
|
source_handle = open(source_location, "rb")
|
|
|
|
|
elif source_protocol in {"FTP", "SFTP"}:
|
|
|
|
|
# FTP/SFTP: download file to an in-memory buffer first
|
|
|
|
|
import io
|
|
|
|
|
from ftplib import FTP as _FTP
|
|
|
|
|
buf = io.BytesIO()
|
|
|
|
|
ftp_dl = _FTP()
|
|
|
|
|
# source_location is the FTP path; connection info comes from the job context
|
|
|
|
|
# We store it as "host:port|username|password|path" for FTP sources
|
|
|
|
|
parts = source_location.split("|", 3)
|
|
|
|
|
if len(parts) == 4:
|
|
|
|
|
ftp_host_port, ftp_user, ftp_pass, ftp_path = parts
|
|
|
|
|
_h, _p = (ftp_host_port.rsplit(":", 1) + [None])[:2]
|
|
|
|
|
ftp_dl.connect(_h, int(_p) if _p else 21, timeout=60)
|
|
|
|
|
ftp_dl.login(ftp_user, ftp_pass)
|
|
|
|
|
ftp_dl.retrbinary(f"RETR {ftp_path}", buf.write)
|
|
|
|
|
ftp_dl.quit()
|
|
|
|
|
buf.seek(0)
|
|
|
|
|
source_handle = buf
|
|
|
|
|
else:
|
|
|
|
|
raise RuntimeError(f"Ungültiger FTP-Source-Pfad: {source_location}")
|
|
|
|
|
else:
|
|
|
|
|
source_handle = smbclient.open_file(
|
|
|
|
|
self._smb_unc_path(*source_location.split("|", 2)),
|
|
|
|
|
mode="rb",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if destination_protocol == "LOCAL":
|
|
|
|
|
destination_handle = open(destination_location, "wb")
|
|
|
|
|
elif destination_protocol in {"FTP", "SFTP"}:
|
|
|
|
|
import io as _io
|
|
|
|
|
from ftplib import FTP as _FTP
|
|
|
|
|
parts = destination_location.split("|", 3)
|
|
|
|
|
if len(parts) != 4:
|
|
|
|
|
raise RuntimeError(f"Ungültiger FTP-Ziel-Pfad: {destination_location}")
|
|
|
|
|
ftp_host_port, ftp_user, ftp_pass, ftp_path = parts
|
|
|
|
|
_h, _p = (ftp_host_port.rsplit(":", 1) + [None])[:2]
|
|
|
|
|
ftp_dest = _FTP()
|
|
|
|
|
try:
|
|
|
|
|
ftp_dest.connect(_h, int(_p) if _p else 21, timeout=60)
|
|
|
|
|
ftp_dest.login(ftp_user, ftp_pass)
|
|
|
|
|
# Ensure parent directories exist
|
|
|
|
|
parent_dirs = [d for d in (ftp_path.rsplit("/", 1)[0] if "/" in ftp_path else "").split("/") if d]
|
|
|
|
|
cur = ""
|
|
|
|
|
for _d in parent_dirs:
|
|
|
|
|
cur = f"{cur}/{_d}"
|
|
|
|
|
try: ftp_dest.mkd(cur)
|
|
|
|
|
except Exception: pass
|
|
|
|
|
src_buf = _io.BytesIO(source_handle.read())
|
|
|
|
|
source_handle.close()
|
|
|
|
|
ftp_dest.storbinary(f"STOR {ftp_path}", src_buf)
|
|
|
|
|
return len(src_buf.getvalue())
|
|
|
|
|
except Exception as _exc:
|
|
|
|
|
raise RuntimeError(f"FTP-Upload fehlgeschlagen: {_exc}") from _exc
|
|
|
|
|
finally:
|
|
|
|
|
try: ftp_dest.quit()
|
|
|
|
|
except Exception: pass
|
|
|
|
|
else:
|
|
|
|
|
destination_handle = smbclient.open_file(
|
|
|
|
|
self._smb_unc_path(*destination_location.split("|", 2)),
|
|
|
|
|
mode="wb",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
while True:
|
|
|
|
|
if stop_event.is_set():
|
|
|
|
|
raise RuntimeError("Lauf wurde abgebrochen.")
|
|
|
|
|
chunk = source_handle.read(1024 * 1024)
|
|
|
|
|
if not chunk:
|
|
|
|
|
break
|
|
|
|
|
destination_handle.write(chunk)
|
|
|
|
|
total_bytes += len(chunk)
|
|
|
|
|
finally:
|
|
|
|
|
source_handle.close()
|
|
|
|
|
destination_handle.close()
|
|
|
|
|
|
|
|
|
|
return total_bytes
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _compression_extension(compression_method: str) -> str:
|
|
|
|
|
mapping = {
|
|
|
|
|
"zip": ".zip",
|
|
|
|
|
"tar.gz": ".tar.gz",
|
|
|
|
|
"tar.bz2": ".tar.bz2",
|
|
|
|
|
"tar.xz": ".tar.xz",
|
|
|
|
|
"7z": ".7z",
|
|
|
|
|
}
|
|
|
|
|
return mapping.get(compression_method, "")
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _ensure_archive_suffix(filename: str, compression_method: str) -> str:
|
|
|
|
|
ext = FlaskServer._compression_extension(compression_method)
|
|
|
|
|
if not ext:
|
|
|
|
|
return filename
|
|
|
|
|
lower_name = filename.lower()
|
|
|
|
|
if lower_name.endswith(ext):
|
|
|
|
|
return filename
|
|
|
|
|
return f"{filename}{ext}"
|
|
|
|
|
|
|
|
|
|
def _build_archive_file(
|
|
|
|
|
self,
|
|
|
|
|
job: BackupExecutionJob,
|
|
|
|
|
source_files: list[tuple[str, str]],
|
|
|
|
|
source_protocol: str,
|
|
|
|
|
stop_event: threading.Event,
|
|
|
|
|
) -> tuple[Path, str, int]:
|
|
|
|
|
method = job.compression_method
|
|
|
|
|
if method not in {"zip", "tar.gz", "tar.bz2", "tar.xz", "7z"}:
|
|
|
|
|
raise RuntimeError(f"Kompressionsmethode '{method}' wird nicht unterstützt.")
|
|
|
|
|
|
|
|
|
|
if method == "7z" and py7zr is None:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
"7z-Unterstützung ist nicht verfügbar. Bitte py7zr installieren und App neu starten."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
target_base = self._replace_target_tokens(job.target_pattern).strip()
|
|
|
|
|
if not target_base:
|
|
|
|
|
target_base = f"backup_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}"
|
|
|
|
|
archive_name = self._ensure_archive_suffix(target_base, method)
|
|
|
|
|
|
|
|
|
|
work_dir = Path(tempfile.mkdtemp(prefix="stfv-backup-"))
|
|
|
|
|
staging_dir = work_dir / "staging"
|
|
|
|
|
staging_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
total_count = len(source_files)
|
|
|
|
|
for index, (relative_path, source_path) in enumerate(source_files, start=1):
|
|
|
|
|
if stop_event.is_set():
|
|
|
|
|
raise RuntimeError("Lauf wurde abgebrochen.")
|
|
|
|
|
|
|
|
|
|
local_target = staging_dir / relative_path
|
|
|
|
|
local_target.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
source_spec = self._build_source_spec(job, source_path, source_protocol)
|
|
|
|
|
self._copy_file_stream(
|
|
|
|
|
source_protocol=source_protocol,
|
|
|
|
|
source_location=source_spec,
|
|
|
|
|
destination_protocol="LOCAL",
|
|
|
|
|
destination_location=str(local_target),
|
|
|
|
|
stop_event=stop_event,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
progress = 52 + int((index / max(1, total_count)) * 18)
|
|
|
|
|
self._set_run_state(
|
|
|
|
|
job.backup_id,
|
|
|
|
|
is_running=True,
|
|
|
|
|
progress_percent=min(progress, 70),
|
|
|
|
|
progress_text="Sammle Dateien...",
|
|
|
|
|
status_message=f"Datei {index}/{total_count} für Archiv vorbereitet.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
archive_path = work_dir / archive_name
|
|
|
|
|
|
|
|
|
|
self._set_run_state(
|
|
|
|
|
job.backup_id,
|
|
|
|
|
is_running=True,
|
|
|
|
|
progress_percent=74,
|
|
|
|
|
progress_text="Komprimiere...",
|
|
|
|
|
status_message=f"Erzeuge {method}-Archiv...",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if method == "zip":
|
|
|
|
|
with zipfile.ZipFile(archive_path, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
|
|
|
for file_path in staging_dir.rglob("*"):
|
|
|
|
|
if file_path.is_file():
|
|
|
|
|
arcname = file_path.relative_to(staging_dir).as_posix()
|
|
|
|
|
zf.write(file_path, arcname=arcname)
|
|
|
|
|
elif method in {"tar.gz", "tar.bz2", "tar.xz"}:
|
|
|
|
|
mode_map = {
|
|
|
|
|
"tar.gz": "w:gz",
|
|
|
|
|
"tar.bz2": "w:bz2",
|
|
|
|
|
"tar.xz": "w:xz",
|
|
|
|
|
}
|
|
|
|
|
with tarfile.open(archive_path, mode=mode_map[method]) as tf:
|
|
|
|
|
for file_path in staging_dir.rglob("*"):
|
|
|
|
|
if file_path.is_file():
|
|
|
|
|
arcname = file_path.relative_to(staging_dir).as_posix()
|
|
|
|
|
tf.add(file_path, arcname=arcname)
|
|
|
|
|
else:
|
|
|
|
|
assert py7zr is not None
|
|
|
|
|
with py7zr.SevenZipFile(archive_path, mode="w") as sz:
|
|
|
|
|
for file_path in staging_dir.rglob("*"):
|
|
|
|
|
if file_path.is_file():
|
|
|
|
|
arcname = file_path.relative_to(staging_dir).as_posix()
|
|
|
|
|
sz.write(file_path, arcname=arcname)
|
|
|
|
|
|
|
|
|
|
archive_bytes = archive_path.stat().st_size
|
|
|
|
|
return archive_path, archive_name, archive_bytes
|
|
|
|
|
|
|
|
|
|
def _list_destination_top_entries(self, job: BackupExecutionJob) -> list[tuple[str, float]]:
|
|
|
|
|
"""Returns [(name, mtime)] for direct children of destination_subpath."""
|
|
|
|
|
protocol = job.destination_protocol.upper()
|
|
|
|
|
subpath = normalize_subpath(job.destination_subpath)
|
|
|
|
|
|
|
|
|
|
if protocol == "LOCAL":
|
|
|
|
|
result: list[tuple[str, float]] = []
|
|
|
|
|
root = Path(job.destination_subpath).expanduser()
|
|
|
|
|
if not root.exists():
|
|
|
|
|
return result
|
|
|
|
|
for entry in root.iterdir():
|
|
|
|
|
try:
|
|
|
|
|
result.append((entry.name, entry.stat().st_mtime))
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
if protocol == "SMB":
|
|
|
|
|
result = []
|
|
|
|
|
unc = self._smb_unc_path(job.destination_host, job.destination_share, subpath)
|
|
|
|
|
try:
|
|
|
|
|
for item in smbclient.scandir(unc):
|
|
|
|
|
if item.name in {".", ".."}:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
result.append((item.name, float(item.stat().st_mtime or 0)))
|
|
|
|
|
except Exception:
|
|
|
|
|
result.append((item.name, 0.0))
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
if protocol == "FTP":
|
|
|
|
|
from ftplib import FTP as _FTP
|
|
|
|
|
from datetime import datetime as _dt
|
|
|
|
|
result = []
|
|
|
|
|
ftp = _FTP()
|
|
|
|
|
try:
|
|
|
|
|
ftp.connect(job.destination_host, job.destination_port or 21, timeout=30)
|
|
|
|
|
ftp.login(job.destination_username, job.destination_password)
|
|
|
|
|
try:
|
|
|
|
|
for name, facts in ftp.mlsd(subpath):
|
|
|
|
|
if name in (".", ".."):
|
|
|
|
|
continue
|
|
|
|
|
mtime = 0.0
|
|
|
|
|
mts = facts.get("modify", "")
|
|
|
|
|
if mts:
|
|
|
|
|
try:
|
|
|
|
|
mtime = _dt.strptime(mts[:14], "%Y%m%d%H%M%S").timestamp()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
result.append((name, mtime))
|
|
|
|
|
except Exception:
|
|
|
|
|
raw: list[str] = []
|
|
|
|
|
ftp.retrlines(f"LIST {subpath}", raw.append)
|
|
|
|
|
for line in raw:
|
|
|
|
|
parts = line.split(None, 8)
|
|
|
|
|
if len(parts) >= 9 and parts[8].strip() not in (".", ".."):
|
|
|
|
|
result.append((parts[8].strip(), 0.0))
|
|
|
|
|
ftp.quit()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
if protocol == "SFTP":
|
|
|
|
|
import paramiko
|
|
|
|
|
result = []
|
|
|
|
|
client = paramiko.SSHClient()
|
|
|
|
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
|
|
|
try:
|
|
|
|
|
client.connect(
|
|
|
|
|
job.destination_host, port=job.destination_port or 22,
|
|
|
|
|
username=job.destination_username, password=job.destination_password,
|
|
|
|
|
timeout=30,
|
|
|
|
|
)
|
|
|
|
|
sftp = client.open_sftp()
|
|
|
|
|
for attr in sftp.listdir_attr(subpath):
|
|
|
|
|
if attr.filename not in (".", ".."):
|
|
|
|
|
result.append((attr.filename, float(attr.st_mtime or 0)))
|
|
|
|
|
sftp.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
finally:
|
|
|
|
|
client.close()
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
def _smb_rmdir_recursive(self, host: str, share: str, subpath: str) -> None:
|
|
|
|
|
unc = self._smb_unc_path(host, share, subpath)
|
|
|
|
|
try:
|
|
|
|
|
for item in smbclient.scandir(unc):
|
|
|
|
|
if item.name in {".", ".."}:
|
|
|
|
|
continue
|
|
|
|
|
child = self._join_subpath(subpath, item.name)
|
|
|
|
|
if item.is_dir():
|
|
|
|
|
self._smb_rmdir_recursive(host, share, child)
|
|
|
|
|
else:
|
|
|
|
|
try:
|
|
|
|
|
smbclient.remove(self._smb_unc_path(host, share, child))
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
smbclient.rmdir(unc)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _ftp_delete_recursive(ftp, path: str) -> None:
|
|
|
|
|
try:
|
|
|
|
|
ftp.delete(path)
|
|
|
|
|
return
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
raw: list[str] = []
|
|
|
|
|
try:
|
|
|
|
|
ftp.retrlines(f"LIST {path}", raw.append)
|
|
|
|
|
except Exception:
|
|
|
|
|
return
|
|
|
|
|
for line in raw:
|
|
|
|
|
parts = line.split(None, 8)
|
|
|
|
|
if len(parts) < 9:
|
|
|
|
|
continue
|
|
|
|
|
name = parts[8].strip()
|
|
|
|
|
if name in (".", ".."):
|
|
|
|
|
continue
|
|
|
|
|
child = f"{path.rstrip('/')}/{name}"
|
|
|
|
|
if parts[0].startswith("d"):
|
|
|
|
|
FlaskServer._ftp_delete_recursive(ftp, child)
|
|
|
|
|
else:
|
|
|
|
|
try:
|
|
|
|
|
ftp.delete(child)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
ftp.rmd(path)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _sftp_delete_recursive(sftp, path: str) -> None:
|
|
|
|
|
import stat as _stat
|
|
|
|
|
try:
|
|
|
|
|
attrs = sftp.listdir_attr(path)
|
|
|
|
|
except Exception:
|
|
|
|
|
try:
|
|
|
|
|
sftp.remove(path)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return
|
|
|
|
|
for attr in attrs:
|
|
|
|
|
child = f"{path.rstrip('/')}/{attr.filename}"
|
|
|
|
|
if _stat.S_ISDIR(attr.st_mode or 0):
|
|
|
|
|
FlaskServer._sftp_delete_recursive(sftp, child)
|
|
|
|
|
else:
|
|
|
|
|
try:
|
|
|
|
|
sftp.remove(child)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
sftp.rmdir(path)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _delete_destination_entry(self, job: BackupExecutionJob, entry_name: str) -> None:
|
|
|
|
|
protocol = job.destination_protocol.upper()
|
|
|
|
|
child_subpath = normalize_subpath(self._join_subpath(job.destination_subpath, entry_name))
|
|
|
|
|
try:
|
|
|
|
|
if protocol == "LOCAL":
|
|
|
|
|
target = Path(job.destination_subpath).expanduser() / entry_name
|
|
|
|
|
if target.is_dir():
|
|
|
|
|
shutil.rmtree(target, ignore_errors=True)
|
|
|
|
|
else:
|
|
|
|
|
target.unlink(missing_ok=True)
|
|
|
|
|
elif protocol == "SMB":
|
|
|
|
|
unc = self._smb_unc_path(job.destination_host, job.destination_share, child_subpath)
|
|
|
|
|
try:
|
|
|
|
|
smbclient.remove(unc)
|
|
|
|
|
except Exception:
|
|
|
|
|
self._smb_rmdir_recursive(job.destination_host, job.destination_share, child_subpath)
|
|
|
|
|
elif protocol == "FTP":
|
|
|
|
|
from ftplib import FTP as _FTP
|
|
|
|
|
ftp = _FTP()
|
|
|
|
|
try:
|
|
|
|
|
ftp.connect(job.destination_host, job.destination_port or 21, timeout=30)
|
|
|
|
|
ftp.login(job.destination_username, job.destination_password)
|
|
|
|
|
self._ftp_delete_recursive(ftp, child_subpath)
|
|
|
|
|
ftp.quit()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
elif protocol == "SFTP":
|
|
|
|
|
import paramiko
|
|
|
|
|
client = paramiko.SSHClient()
|
|
|
|
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
|
|
|
try:
|
|
|
|
|
client.connect(
|
|
|
|
|
job.destination_host, port=job.destination_port or 22,
|
|
|
|
|
username=job.destination_username, password=job.destination_password,
|
|
|
|
|
timeout=30,
|
|
|
|
|
)
|
|
|
|
|
sftp = client.open_sftp()
|
|
|
|
|
self._sftp_delete_recursive(sftp, child_subpath)
|
|
|
|
|
sftp.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
finally:
|
|
|
|
|
client.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _apply_rotation(self, job: BackupExecutionJob) -> None:
|
|
|
|
|
"""Delete oldest destination entries if count exceeds rotation_keep."""
|
|
|
|
|
if job.rotation_keep <= 0 or job.backup_mode in {"sync", "clone"}:
|
|
|
|
|
return
|
|
|
|
|
entries = self._list_destination_top_entries(job)
|
|
|
|
|
if len(entries) <= job.rotation_keep:
|
|
|
|
|
return
|
|
|
|
|
entries.sort(key=lambda x: x[1]) # oldest first
|
|
|
|
|
for name, _ in entries[:len(entries) - job.rotation_keep]:
|
|
|
|
|
self._delete_destination_entry(job, name)
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
# Sync / Clone helpers #
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
|
|
|
|
def _build_file_tree_local(self, subpath: str) -> dict[str, tuple[int, float]]:
|
|
|
|
|
result: dict[str, tuple[int, float]] = {}
|
|
|
|
|
root = Path(subpath).expanduser()
|
|
|
|
|
if not root.is_dir():
|
|
|
|
|
return result
|
|
|
|
|
for entry in root.rglob("*"):
|
|
|
|
|
if entry.is_file():
|
|
|
|
|
st = entry.stat()
|
|
|
|
|
result[entry.relative_to(root).as_posix()] = (st.st_size, st.st_mtime)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
def _build_file_tree_smb(self, host: str, share: str, subpath: str) -> dict[str, tuple[int, float]]:
|
|
|
|
|
result: dict[str, tuple[int, float]] = {}
|
|
|
|
|
|
|
|
|
|
def walk(current: str, prefix: str) -> None:
|
|
|
|
|
unc = self._smb_unc_path(host, share, current)
|
|
|
|
|
try:
|
|
|
|
|
entries = list(smbclient.scandir(unc))
|
|
|
|
|
except Exception:
|
|
|
|
|
return
|
|
|
|
|
for item in entries:
|
|
|
|
|
if item.name in {".", ".."}:
|
|
|
|
|
continue
|
|
|
|
|
child = self._join_subpath(current, item.name)
|
|
|
|
|
rel = f"{prefix}/{item.name}" if prefix else item.name
|
|
|
|
|
if item.is_dir():
|
|
|
|
|
walk(child, rel)
|
|
|
|
|
else:
|
|
|
|
|
try:
|
|
|
|
|
st = item.stat()
|
|
|
|
|
result[rel] = (st.st_size, float(st.st_mtime or 0))
|
|
|
|
|
except Exception:
|
|
|
|
|
result[rel] = (0, 0.0)
|
|
|
|
|
|
|
|
|
|
walk(normalize_subpath(subpath), "")
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
def _build_file_tree_ftp(
|
|
|
|
|
self, host: str, port: int | None, username: str, password: str, subpath: str
|
|
|
|
|
) -> dict[str, tuple[int, float]]:
|
|
|
|
|
from ftplib import FTP as _FTP
|
|
|
|
|
from datetime import datetime as _dt
|
|
|
|
|
result: dict[str, tuple[int, float]] = {}
|
|
|
|
|
ftp = _FTP()
|
|
|
|
|
try:
|
|
|
|
|
ftp.connect(host, port or 21, timeout=30)
|
|
|
|
|
ftp.login(username, password)
|
|
|
|
|
|
|
|
|
|
def walk(path: str, prefix: str) -> None:
|
|
|
|
|
# Prefer MLSD (reliable size + mtime) over LIST
|
|
|
|
|
try:
|
|
|
|
|
entries = list(ftp.mlsd(path))
|
|
|
|
|
for name, facts in entries:
|
|
|
|
|
if name in (".", ".."):
|
|
|
|
|
continue
|
|
|
|
|
child = f"{path.rstrip('/')}/{name}"
|
|
|
|
|
rel = f"{prefix}/{name}" if prefix else name
|
|
|
|
|
if facts.get("type") == "dir":
|
|
|
|
|
walk(child, rel)
|
|
|
|
|
else:
|
|
|
|
|
size = int(facts.get("size", 0))
|
|
|
|
|
mtime = 0.0
|
|
|
|
|
mts = facts.get("modify", "")
|
|
|
|
|
if mts:
|
|
|
|
|
try:
|
|
|
|
|
mtime = _dt.strptime(mts[:14], "%Y%m%d%H%M%S").timestamp()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
result[rel] = (size, mtime)
|
|
|
|
|
return
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Fallback: LIST
|
|
|
|
|
raw: list[str] = []
|
|
|
|
|
try:
|
|
|
|
|
ftp.retrlines(f"LIST {path}", raw.append)
|
|
|
|
|
except Exception:
|
|
|
|
|
return
|
|
|
|
|
for line in raw:
|
|
|
|
|
parts = line.split(None, 8)
|
|
|
|
|
if len(parts) < 9:
|
|
|
|
|
continue
|
|
|
|
|
name = parts[8].strip()
|
|
|
|
|
if name in (".", ".."):
|
|
|
|
|
continue
|
|
|
|
|
is_dir = parts[0].startswith("d")
|
|
|
|
|
child = f"{path.rstrip('/')}/{name}"
|
|
|
|
|
rel = f"{prefix}/{name}" if prefix else name
|
|
|
|
|
if is_dir:
|
|
|
|
|
walk(child, rel)
|
|
|
|
|
else:
|
|
|
|
|
size = int(parts[4]) if parts[4].isdigit() else 0
|
|
|
|
|
result[rel] = (size, 0.0)
|
|
|
|
|
|
|
|
|
|
walk(normalize_subpath(subpath), "")
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
ftp.quit()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
def _build_file_tree_sftp(
|
|
|
|
|
self, host: str, port: int | None, username: str, password: str, subpath: str
|
|
|
|
|
) -> dict[str, tuple[int, float]]:
|
|
|
|
|
import stat as _stat
|
|
|
|
|
import paramiko
|
|
|
|
|
result: dict[str, tuple[int, float]] = {}
|
|
|
|
|
client = paramiko.SSHClient()
|
|
|
|
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
|
|
|
try:
|
|
|
|
|
client.connect(host, port=port or 22, username=username, password=password, timeout=30)
|
|
|
|
|
sftp = client.open_sftp()
|
|
|
|
|
|
|
|
|
|
def walk(path: str, prefix: str) -> None:
|
|
|
|
|
try:
|
|
|
|
|
attrs = sftp.listdir_attr(path)
|
|
|
|
|
except Exception:
|
|
|
|
|
return
|
|
|
|
|
for attr in attrs:
|
|
|
|
|
if attr.filename in (".", ".."):
|
|
|
|
|
continue
|
|
|
|
|
child = f"{path.rstrip('/')}/{attr.filename}"
|
|
|
|
|
rel = f"{prefix}/{attr.filename}" if prefix else attr.filename
|
|
|
|
|
if _stat.S_ISDIR(attr.st_mode or 0):
|
|
|
|
|
walk(child, rel)
|
|
|
|
|
else:
|
|
|
|
|
result[rel] = (attr.st_size or 0, float(attr.st_mtime or 0))
|
|
|
|
|
|
|
|
|
|
walk(normalize_subpath(subpath), "")
|
|
|
|
|
sftp.close()
|
|
|
|
|
finally:
|
|
|
|
|
client.close()
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
def _build_file_tree(self, job: BackupExecutionJob, side: str) -> dict[str, tuple[int, float]]:
|
|
|
|
|
if side == "source":
|
|
|
|
|
protocol = job.source_protocol.upper()
|
|
|
|
|
host, port, share = job.source_host, job.source_port, job.source_share
|
|
|
|
|
subpath, username, password = job.source_subpath, job.source_username, job.source_password
|
|
|
|
|
else:
|
|
|
|
|
protocol = job.destination_protocol.upper()
|
|
|
|
|
host, port, share = job.destination_host, job.destination_port, job.destination_share
|
|
|
|
|
subpath, username, password = job.destination_subpath, job.destination_username, job.destination_password
|
|
|
|
|
|
|
|
|
|
if protocol == "LOCAL":
|
|
|
|
|
return self._build_file_tree_local(subpath)
|
|
|
|
|
if protocol == "SMB":
|
|
|
|
|
return self._build_file_tree_smb(host, share, subpath)
|
|
|
|
|
if protocol == "FTP":
|
|
|
|
|
return self._build_file_tree_ftp(host, port, username, password, subpath)
|
|
|
|
|
if protocol == "SFTP":
|
|
|
|
|
return self._build_file_tree_sftp(host, port, username, password, subpath)
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
def _delete_destination_file(
|
|
|
|
|
self, protocol: str, job: BackupExecutionJob, rel_path: str
|
|
|
|
|
) -> None:
|
|
|
|
|
subpath = self._join_subpath(job.destination_subpath, rel_path)
|
|
|
|
|
try:
|
|
|
|
|
if protocol == "LOCAL":
|
|
|
|
|
Path(job.destination_subpath).expanduser().joinpath(rel_path).unlink(missing_ok=True)
|
|
|
|
|
elif protocol == "SMB":
|
|
|
|
|
unc = self._smb_unc_path(job.destination_host, job.destination_share, subpath)
|
|
|
|
|
smbclient.remove(unc)
|
|
|
|
|
elif protocol == "FTP":
|
|
|
|
|
from ftplib import FTP as _FTP
|
|
|
|
|
ftp = _FTP()
|
|
|
|
|
ftp.connect(job.destination_host, job.destination_port or 21, timeout=30)
|
|
|
|
|
ftp.login(job.destination_username, job.destination_password)
|
|
|
|
|
ftp.delete(subpath)
|
|
|
|
|
ftp.quit()
|
|
|
|
|
elif protocol == "SFTP":
|
|
|
|
|
import paramiko
|
|
|
|
|
client = paramiko.SSHClient()
|
|
|
|
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
|
|
|
client.connect(
|
|
|
|
|
job.destination_host, port=job.destination_port or 22,
|
|
|
|
|
username=job.destination_username, password=job.destination_password,
|
|
|
|
|
timeout=30,
|
|
|
|
|
)
|
|
|
|
|
client.open_sftp().remove(subpath)
|
|
|
|
|
client.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _sync_backup_payload(self, job: BackupExecutionJob, stop_event: threading.Event) -> tuple[int, int]:
|
|
|
|
|
src_proto = job.source_protocol.upper()
|
|
|
|
|
dst_proto = job.destination_protocol.upper()
|
|
|
|
|
|
|
|
|
|
if src_proto == "SMB":
|
|
|
|
|
smbclient.register_session(
|
|
|
|
|
server=job.source_host, username=job.source_username,
|
|
|
|
|
password=job.source_password, port=job.source_port or 445,
|
|
|
|
|
)
|
|
|
|
|
if dst_proto == "SMB":
|
|
|
|
|
smbclient.register_session(
|
|
|
|
|
server=job.destination_host, username=job.destination_username,
|
|
|
|
|
password=job.destination_password, port=job.destination_port or 445,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self._set_run_state(
|
|
|
|
|
job.backup_id, is_running=True, progress_percent=10,
|
|
|
|
|
progress_text="Scanne Quelle...", status_message="Dateibaum der Quelle wird gelesen...",
|
|
|
|
|
)
|
|
|
|
|
source_tree = self._build_file_tree(job, "source")
|
|
|
|
|
|
|
|
|
|
self._set_run_state(
|
|
|
|
|
job.backup_id, is_running=True, progress_percent=25,
|
|
|
|
|
progress_text="Scanne Ziel...", status_message="Dateibaum des Ziels wird gelesen...",
|
|
|
|
|
)
|
|
|
|
|
dest_tree = self._build_file_tree(job, "destination")
|
|
|
|
|
|
|
|
|
|
# Determine changed/new files
|
|
|
|
|
to_transfer = [
|
|
|
|
|
rel for rel, (src_sz, src_mt) in source_tree.items()
|
|
|
|
|
if rel not in dest_tree
|
|
|
|
|
or dest_tree[rel][0] != src_sz
|
|
|
|
|
or abs(dest_tree[rel][1] - src_mt) > 2
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
# Clone only: files on destination that no longer exist on source
|
|
|
|
|
to_delete = (
|
|
|
|
|
[rel for rel in dest_tree if rel not in source_tree]
|
|
|
|
|
if job.backup_mode == "clone" else []
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
total_ops = len(to_transfer) + len(to_delete)
|
|
|
|
|
if total_ops == 0:
|
|
|
|
|
return 0, 0
|
|
|
|
|
|
|
|
|
|
copied_files = 0
|
|
|
|
|
total_bytes = 0
|
|
|
|
|
|
|
|
|
|
for index, rel_path in enumerate(to_transfer, start=1):
|
|
|
|
|
if stop_event.is_set():
|
|
|
|
|
raise RuntimeError("Lauf wurde abgebrochen.")
|
|
|
|
|
|
|
|
|
|
src_subpath = self._join_subpath(job.source_subpath, rel_path)
|
|
|
|
|
dst_subpath = self._join_subpath(job.destination_subpath, rel_path)
|
|
|
|
|
|
|
|
|
|
if dst_proto == "LOCAL":
|
|
|
|
|
dst_abs = Path(job.destination_subpath).expanduser() / rel_path
|
|
|
|
|
dst_abs.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
dest_spec = str(dst_abs)
|
|
|
|
|
elif dst_proto == "SMB":
|
|
|
|
|
dst_parent = normalize_subpath(posixpath.dirname(dst_subpath))
|
|
|
|
|
self._ensure_smb_dir(job.destination_host, job.destination_share, dst_parent)
|
|
|
|
|
dest_spec = self._build_destination_spec(job, dst_subpath, dst_proto)
|
|
|
|
|
else:
|
|
|
|
|
dest_spec = self._build_destination_spec(job, dst_subpath, dst_proto)
|
|
|
|
|
|
|
|
|
|
src_spec = self._build_source_spec(job, src_subpath, src_proto)
|
|
|
|
|
|
|
|
|
|
total_bytes += self._copy_file_stream(
|
|
|
|
|
source_protocol=src_proto,
|
|
|
|
|
source_location=src_spec,
|
|
|
|
|
destination_protocol=dst_proto,
|
|
|
|
|
destination_location=dest_spec,
|
|
|
|
|
stop_event=stop_event,
|
|
|
|
|
)
|
|
|
|
|
copied_files += 1
|
|
|
|
|
|
|
|
|
|
progress = 30 + int((index / max(1, total_ops)) * 60)
|
|
|
|
|
self._set_run_state(
|
|
|
|
|
job.backup_id, is_running=True,
|
|
|
|
|
progress_percent=min(progress, 90),
|
|
|
|
|
progress_text="Synchronisiere...",
|
|
|
|
|
status_message=f"{index}/{len(to_transfer)} Dateien übertragen.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
for rel_path in to_delete:
|
|
|
|
|
if stop_event.is_set():
|
|
|
|
|
raise RuntimeError("Lauf wurde abgebrochen.")
|
|
|
|
|
self._delete_destination_file(dst_proto, job, rel_path)
|
|
|
|
|
|
|
|
|
|
return copied_files, total_bytes
|
|
|
|
|
|
|
|
|
|
def _transfer_backup_payload(self, job: BackupExecutionJob, stop_event: threading.Event) -> tuple[int, int]:
|
|
|
|
|
source_protocol = job.source_protocol.upper()
|
|
|
|
|
destination_protocol = job.destination_protocol.upper()
|
|
|
|
|
if source_protocol not in {"LOCAL", "SMB", "FTP", "SFTP"}:
|
|
|
|
|
raise RuntimeError(f"Source-Protokoll '{source_protocol}' ist nicht unterstützt.")
|
|
|
|
|
if destination_protocol not in {"LOCAL", "SMB", "FTP", "SFTP"}:
|
|
|
|
|
raise RuntimeError(f"Destination-Protokoll '{destination_protocol}' ist nicht unterstützt.")
|
|
|
|
|
|
|
|
|
|
if job.backup_mode in {"sync", "clone"}:
|
|
|
|
|
return self._sync_backup_payload(job, stop_event)
|
|
|
|
|
|
|
|
|
|
if destination_protocol == "SMB":
|
|
|
|
|
smbclient.register_session(
|
|
|
|
|
server=job.destination_host,
|
|
|
|
|
username=job.destination_username,
|
|
|
|
|
password=job.destination_password,
|
|
|
|
|
port=job.destination_port or 445,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
source_files = self._collect_source_files(job)
|
|
|
|
|
if not source_files:
|
|
|
|
|
raise RuntimeError("Keine Dateien in der Quelle gefunden.")
|
|
|
|
|
|
|
|
|
|
if job.compression_method != "none":
|
|
|
|
|
archive_path, archive_name, archive_bytes = self._build_archive_file(
|
|
|
|
|
job=job,
|
|
|
|
|
source_files=source_files,
|
|
|
|
|
source_protocol=source_protocol,
|
|
|
|
|
stop_event=stop_event,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
destination_base = normalize_subpath(job.destination_subpath)
|
|
|
|
|
# archive_name already encodes the target_pattern with resolved tokens + extension
|
|
|
|
|
archive_rel = archive_name
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
if destination_protocol == "LOCAL":
|
|
|
|
|
destination_abs = (Path(job.destination_subpath).expanduser() / archive_rel).resolve()
|
|
|
|
|
destination_abs.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
destination_spec = str(destination_abs)
|
|
|
|
|
elif destination_protocol in {"FTP", "SFTP"}:
|
|
|
|
|
destination_subpath = self._join_subpath(destination_base, archive_rel)
|
|
|
|
|
destination_spec = self._build_destination_spec(job, destination_subpath, destination_protocol)
|
|
|
|
|
else:
|
|
|
|
|
destination_subpath = self._join_subpath(destination_base, archive_rel)
|
|
|
|
|
destination_parent = normalize_subpath(posixpath.dirname(destination_subpath))
|
|
|
|
|
self._ensure_smb_dir(job.destination_host, job.destination_share, destination_parent)
|
|
|
|
|
destination_spec = "|".join([job.destination_host, job.destination_share, destination_subpath])
|
|
|
|
|
|
|
|
|
|
self._set_run_state(
|
|
|
|
|
job.backup_id,
|
|
|
|
|
is_running=True,
|
|
|
|
|
progress_percent=90,
|
|
|
|
|
progress_text="Übertrage Archiv...",
|
|
|
|
|
status_message=f"Schreibe Archiv {archive_name} ins Ziel...",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
transferred = self._copy_file_stream(
|
|
|
|
|
source_protocol="LOCAL",
|
|
|
|
|
source_location=str(archive_path),
|
|
|
|
|
destination_protocol=destination_protocol,
|
|
|
|
|
destination_location=destination_spec,
|
|
|
|
|
stop_event=stop_event,
|
|
|
|
|
)
|
|
|
|
|
return 1, max(transferred, archive_bytes)
|
|
|
|
|
finally:
|
|
|
|
|
shutil.rmtree(archive_path.parent, ignore_errors=True)
|
|
|
|
|
|
|
|
|
|
target_pattern = self._replace_target_tokens(job.target_pattern)
|
|
|
|
|
destination_base = normalize_subpath(job.destination_subpath)
|
|
|
|
|
|
|
|
|
|
if job.target_kind == "file" and len(source_files) != 1:
|
|
|
|
|
raise RuntimeError("Target ist Datei, aber die Quelle enthält mehrere Dateien.")
|
|
|
|
|
|
|
|
|
|
copied_files = 0
|
|
|
|
|
total_bytes = 0
|
|
|
|
|
total_count = len(source_files)
|
|
|
|
|
|
|
|
|
|
for index, (relative_path, source_path) in enumerate(source_files, start=1):
|
|
|
|
|
if stop_event.is_set():
|
|
|
|
|
raise RuntimeError("Lauf wurde abgebrochen.")
|
|
|
|
|
|
|
|
|
|
if job.target_kind == "file":
|
|
|
|
|
destination_rel = target_pattern
|
|
|
|
|
else:
|
|
|
|
|
destination_rel = f"{target_pattern}/{relative_path}" if target_pattern else relative_path
|
|
|
|
|
|
|
|
|
|
if destination_protocol == "LOCAL":
|
|
|
|
|
destination_abs = (Path(job.destination_subpath).expanduser() / destination_rel).resolve()
|
|
|
|
|
destination_abs.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
destination_spec = str(destination_abs)
|
|
|
|
|
elif destination_protocol in {"FTP", "SFTP"}:
|
|
|
|
|
destination_subpath = self._join_subpath(destination_base, destination_rel)
|
|
|
|
|
destination_spec = self._build_destination_spec(job, destination_subpath, destination_protocol)
|
|
|
|
|
else:
|
|
|
|
|
destination_subpath = self._join_subpath(destination_base, destination_rel)
|
|
|
|
|
destination_parent = normalize_subpath(posixpath.dirname(destination_subpath))
|
|
|
|
|
self._ensure_smb_dir(job.destination_host, job.destination_share, destination_parent)
|
|
|
|
|
destination_spec = "|".join([job.destination_host, job.destination_share, destination_subpath])
|
|
|
|
|
|
|
|
|
|
source_spec = self._build_source_spec(job, source_path, source_protocol)
|
|
|
|
|
|
|
|
|
|
total_bytes += self._copy_file_stream(
|
|
|
|
|
source_protocol=source_protocol,
|
|
|
|
|
source_location=source_spec,
|
|
|
|
|
destination_protocol=destination_protocol,
|
|
|
|
|
destination_location=destination_spec,
|
|
|
|
|
stop_event=stop_event,
|
|
|
|
|
)
|
|
|
|
|
copied_files += 1
|
|
|
|
|
|
|
|
|
|
progress = 50 + int((index / max(1, total_count)) * 45)
|
|
|
|
|
self._set_run_state(
|
|
|
|
|
job.backup_id,
|
|
|
|
|
is_running=True,
|
|
|
|
|
progress_percent=min(progress, 95),
|
|
|
|
|
progress_text="Übertrage Daten...",
|
|
|
|
|
status_message=f"Datei {index}/{total_count} wurde übertragen.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return copied_files, total_bytes
|
|
|
|
|
|
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:
|
2026-08-11 22:45:58 +02:00
|
|
|
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
|
|
|
|
2026-08-11 22:45:58 +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-07-29 17:36:22 +02:00
|
|
|
self._set_run_state(
|
|
|
|
|
backup_id,
|
2026-08-11 22:45:58 +02:00
|
|
|
is_running=False,
|
|
|
|
|
progress_percent=0,
|
|
|
|
|
progress_text="Fehler",
|
|
|
|
|
status_message=f"Quelle nicht erreichbar: {msg}",
|
2026-07-29 17:36:22 +02:00
|
|
|
)
|
2026-08-11 22:45:58 +02:00
|
|
|
return
|
2026-07-29 17:36:22 +02:00
|
|
|
|
2026-08-11 22:45:58 +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:
|
|
|
|
|
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
|
|
|
|
2026-08-11 22:45:58 +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
|
|
|
|
2026-08-11 22:45:58 +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():
|
|
|
|
|
self._set_run_state(
|
|
|
|
|
backup_id,
|
|
|
|
|
is_running=False,
|
|
|
|
|
progress_percent=0,
|
|
|
|
|
progress_text="Gestoppt",
|
|
|
|
|
status_message="Lauf wurde manuell gestoppt.",
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
2026-08-11 22:45:58 +02:00
|
|
|
self.store.mark_backup_run(backup_id, copied_bytes)
|
|
|
|
|
self._apply_rotation(job)
|
2026-07-29 17:36:22 +02:00
|
|
|
self._set_run_state(
|
|
|
|
|
backup_id,
|
|
|
|
|
is_running=False,
|
|
|
|
|
progress_percent=100,
|
|
|
|
|
progress_text="Fertig",
|
2026-08-11 22:45:58 +02:00
|
|
|
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}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@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()
|
2026-08-11 22:45:58 +02:00
|
|
|
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
|
|
|
|
|
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):
|
2026-08-11 22:45:58 +02:00
|
|
|
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"))
|
|
|
|
|
|
2026-08-11 22:45:58 +02:00
|
|
|
@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):
|
2026-08-11 22:45:58 +02:00
|
|
|
backup = self.store.get_full_backup_for_edit(backup_id)
|
2026-07-29 17:36:22 +02:00
|
|
|
if backup is None:
|
|
|
|
|
flash("Backup nicht gefunden.", "error")
|
|
|
|
|
return redirect(url_for("dashboard"))
|
|
|
|
|
|
|
|
|
|
if request.method == "POST":
|
2026-08-11 22:45:58 +02:00
|
|
|
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))
|
2026-08-11 22:45:58 +02:00
|
|
|
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))
|
2026-08-11 22:45:58 +02:00
|
|
|
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))
|
|
|
|
|
|
2026-08-11 22:45:58 +02:00
|
|
|
updated = self.store.update_full_backup(
|
2026-07-29 17:36:22 +02:00
|
|
|
backup_id=backup_id,
|
|
|
|
|
name=name,
|
2026-08-11 22:45:58 +02:00
|
|
|
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"],
|
2026-08-11 22:45:58 +02:00
|
|
|
backup_mode=backup_mode,
|
|
|
|
|
rotation_keep=rotation_keep,
|
2026-07-29 17:36:22 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not updated:
|
2026-08-11 22:45:58 +02:00
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
flash("Backup wurde aktualisiert.", "success")
|
|
|
|
|
return redirect(url_for("dashboard"))
|
|
|
|
|
|
|
|
|
|
return render_template(
|
|
|
|
|
"backup_edit.html",
|
|
|
|
|
adminuser=session.get("adminuser", "admin"),
|
|
|
|
|
backup=backup,
|
2026-08-11 22:45:58 +02:00
|
|
|
target_protocols=TARGET_PROTOCOLS,
|
2026-07-29 17:36:22 +02:00
|
|
|
compression_methods=COMPRESSION_METHODS,
|
|
|
|
|
encryption_modes=ENCRYPTION_MODES,
|
2026-08-11 22:45:58 +02:00
|
|
|
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:
|
|
|
|
|
flash("Backup nicht gefunden.", "error")
|
|
|
|
|
else:
|
|
|
|
|
flash("Backup wurde gelöscht.", "success")
|
|
|
|
|
return redirect(url_for("dashboard"))
|
|
|
|
|
|
2026-08-11 22:45:58 +02:00
|
|
|
@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})
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-08-11 22:45:58 +02:00
|
|
|
@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", "")
|
2026-08-11 22:45:58 +02:00
|
|
|
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"))
|
2026-08-11 22:45:58 +02:00
|
|
|
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"],
|
2026-08-11 22:45:58 +02:00
|
|
|
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:
|
|
|
|
|
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,
|
2026-08-11 22:45:58 +02:00
|
|
|
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)
|
|
|
|
|
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
|
|
|
|
|
|
2026-08-11 22:45:58 +02:00
|
|
|
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
|
|
|
|
|
|
2026-07-29 17:36:22 +02:00
|
|
|
def run(self) -> None:
|
2026-08-11 22:45:58 +02:00
|
|
|
import os
|
2026-07-29 17:36:22 +02:00
|
|
|
app = self.create_app()
|
2026-08-11 22:45:58 +02:00
|
|
|
# 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-07-29 17:36:22 +02:00
|
|
|
app.run(host=self.config.ip, port=self.config.port, debug=self.config.debug)
|