867 lines
35 KiB
Python
867 lines
35 KiB
Python
import os
|
|||
|
|
import secrets
|
||
|
|
import threading
|
||
|
|
import time
|
||
|
|
from functools import wraps
|
||
|
|
|
||
|
|
from flask import Flask, flash, jsonify, redirect, render_template, request, session, url_for
|
||
|
|
|
||
|
|
from app.config_model import AppConfig, BackupExecutionJob, BackupJob
|
||
|
|
from app.config_store import ConfigStore
|
||
|
|
from app.remote_targets import (
|
||
|
|
TargetConnection,
|
||
|
|
browse_smb_directories,
|
||
|
|
create_smb_directory,
|
||
|
|
list_smb_shares,
|
||
|
|
normalize_subpath,
|
||
|
|
test_connection,
|
||
|
|
)
|
||
|
|
from app.security import hash_password, verify_password
|
||
|
|
|
||
|
|
|
||
|
|
COMPRESSION_METHODS = [
|
||
|
|
"zip",
|
||
|
|
"tar.gz",
|
||
|
|
"tar.bz2",
|
||
|
|
"tar.xz",
|
||
|
|
"7z",
|
||
|
|
]
|
||
|
|
|
||
|
|
TARGET_PROTOCOLS = ["SMB", "LOCAL", "FTP", "SFTP"]
|
||
|
|
ENCRYPTION_MODES = ["none", "password-aes256"]
|
||
|
|
SCHEDULE_MODES = [
|
||
|
|
"daily",
|
||
|
|
"weekly",
|
||
|
|
"monthly",
|
||
|
|
"yearly",
|
||
|
|
"every_n_days",
|
||
|
|
"every_n_weeks",
|
||
|
|
"custom",
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
class FlaskServer:
|
||
|
|
def __init__(self, config: AppConfig, store: ConfigStore) -> None:
|
||
|
|
self.config = config
|
||
|
|
self.store = store
|
||
|
|
self._run_lock = threading.Lock()
|
||
|
|
self._active_runs: dict[int, dict] = {}
|
||
|
|
|
||
|
|
def _get_run_status(self, backup_id: int) -> dict:
|
||
|
|
with self._run_lock:
|
||
|
|
state = self._active_runs.get(backup_id)
|
||
|
|
if not state:
|
||
|
|
return {
|
||
|
|
"is_running": False,
|
||
|
|
"progress_percent": 0,
|
||
|
|
"progress_text": "Bereit",
|
||
|
|
"status_message": "Kein Lauf aktiv",
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
"is_running": bool(state.get("is_running", False)),
|
||
|
|
"progress_percent": int(state.get("progress_percent", 0)),
|
||
|
|
"progress_text": str(state.get("progress_text", "")),
|
||
|
|
"status_message": str(state.get("status_message", "")),
|
||
|
|
}
|
||
|
|
|
||
|
|
def _set_run_state(self, backup_id: int, **values) -> None:
|
||
|
|
with self._run_lock:
|
||
|
|
state = self._active_runs.get(backup_id, {})
|
||
|
|
state.update(values)
|
||
|
|
self._active_runs[backup_id] = state
|
||
|
|
|
||
|
|
def _run_backup_job(self, job: BackupExecutionJob, stop_event: threading.Event) -> None:
|
||
|
|
backup_id = job.backup_id
|
||
|
|
try:
|
||
|
|
steps = [
|
||
|
|
(8, "Initialisiere Lauf..."),
|
||
|
|
(22, "Prüfe Quelle..."),
|
||
|
|
(45, "Prüfe Ziel..."),
|
||
|
|
(70, "Bereite Übertragung vor..."),
|
||
|
|
(88, "Finalisiere..."),
|
||
|
|
]
|
||
|
|
|
||
|
|
for progress, text in steps:
|
||
|
|
if stop_event.is_set():
|
||
|
|
self._set_run_state(
|
||
|
|
backup_id,
|
||
|
|
is_running=False,
|
||
|
|
progress_percent=0,
|
||
|
|
progress_text="Gestoppt",
|
||
|
|
status_message="Lauf wurde manuell gestoppt.",
|
||
|
|
)
|
||
|
|
return
|
||
|
|
|
||
|
|
self._set_run_state(
|
||
|
|
backup_id,
|
||
|
|
is_running=True,
|
||
|
|
progress_percent=progress,
|
||
|
|
progress_text=text,
|
||
|
|
status_message=text,
|
||
|
|
)
|
||
|
|
|
||
|
|
if progress == 22:
|
||
|
|
source = TargetConnection(
|
||
|
|
protocol=job.source_protocol,
|
||
|
|
host=job.source_host,
|
||
|
|
port=job.source_port,
|
||
|
|
share=job.source_share,
|
||
|
|
subpath=job.source_subpath,
|
||
|
|
username=job.source_username,
|
||
|
|
password=job.source_password,
|
||
|
|
)
|
||
|
|
ok, msg = test_connection(source)
|
||
|
|
if not ok:
|
||
|
|
self._set_run_state(
|
||
|
|
backup_id,
|
||
|
|
is_running=False,
|
||
|
|
progress_percent=0,
|
||
|
|
progress_text="Fehler",
|
||
|
|
status_message=f"Quelle nicht erreichbar: {msg}",
|
||
|
|
)
|
||
|
|
return
|
||
|
|
|
||
|
|
if progress == 45:
|
||
|
|
destination = TargetConnection(
|
||
|
|
protocol=job.destination_protocol,
|
||
|
|
host=job.destination_host,
|
||
|
|
port=job.destination_port,
|
||
|
|
share=job.destination_share,
|
||
|
|
subpath=job.destination_subpath,
|
||
|
|
username=job.destination_username,
|
||
|
|
password=job.destination_password,
|
||
|
|
)
|
||
|
|
ok, msg = test_connection(destination)
|
||
|
|
if not ok:
|
||
|
|
self._set_run_state(
|
||
|
|
backup_id,
|
||
|
|
is_running=False,
|
||
|
|
progress_percent=0,
|
||
|
|
progress_text="Fehler",
|
||
|
|
status_message=f"Ziel nicht erreichbar: {msg}",
|
||
|
|
)
|
||
|
|
return
|
||
|
|
|
||
|
|
time.sleep(0.7)
|
||
|
|
|
||
|
|
if stop_event.is_set():
|
||
|
|
self._set_run_state(
|
||
|
|
backup_id,
|
||
|
|
is_running=False,
|
||
|
|
progress_percent=0,
|
||
|
|
progress_text="Gestoppt",
|
||
|
|
status_message="Lauf wurde manuell gestoppt.",
|
||
|
|
)
|
||
|
|
return
|
||
|
|
|
||
|
|
self.store.mark_backup_run(backup_id)
|
||
|
|
self._set_run_state(
|
||
|
|
backup_id,
|
||
|
|
is_running=False,
|
||
|
|
progress_percent=100,
|
||
|
|
progress_text="Fertig",
|
||
|
|
status_message="Backup-Lauf erfolgreich abgeschlossen.",
|
||
|
|
)
|
||
|
|
except Exception as exc: # noqa: BLE001
|
||
|
|
self._set_run_state(
|
||
|
|
backup_id,
|
||
|
|
is_running=False,
|
||
|
|
progress_percent=0,
|
||
|
|
progress_text="Fehler",
|
||
|
|
status_message=f"Lauf fehlgeschlagen: {exc}",
|
||
|
|
)
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _parse_port(value: str) -> int | None:
|
||
|
|
try:
|
||
|
|
port = int(value)
|
||
|
|
except ValueError:
|
||
|
|
return None
|
||
|
|
if 1 <= port <= 65535:
|
||
|
|
return port
|
||
|
|
return None
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _parse_optional_port(value: str) -> int | None:
|
||
|
|
raw = value.strip()
|
||
|
|
if not raw:
|
||
|
|
return None
|
||
|
|
return FlaskServer._parse_port(raw)
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _split_host_and_port(host_input: str) -> tuple[str, int | None, str | None]:
|
||
|
|
raw = host_input.strip()
|
||
|
|
if not raw:
|
||
|
|
return "", None, "Host/IP darf nicht leer sein."
|
||
|
|
|
||
|
|
# IPv6 in Klammern: [fe80::1]:445
|
||
|
|
if raw.startswith("["):
|
||
|
|
end = raw.find("]")
|
||
|
|
if end == -1:
|
||
|
|
return "", None, "IPv6-Host muss in [ ] geklammert sein."
|
||
|
|
host = raw[1:end].strip()
|
||
|
|
rest = raw[end + 1 :].strip()
|
||
|
|
if not rest:
|
||
|
|
return host, None, None
|
||
|
|
if not rest.startswith(":"):
|
||
|
|
return "", None, "Nach IPv6-Host ist nur optional :Port erlaubt."
|
||
|
|
port = FlaskServer._parse_port(rest[1:])
|
||
|
|
if port is None:
|
||
|
|
return "", None, "Port muss zwischen 1 und 65535 liegen."
|
||
|
|
return host, port, None
|
||
|
|
|
||
|
|
# IPv4/FQDN optional mit :port
|
||
|
|
if raw.count(":") == 1:
|
||
|
|
host_part, port_part = raw.rsplit(":", 1)
|
||
|
|
host_part = host_part.strip()
|
||
|
|
port_part = port_part.strip()
|
||
|
|
if port_part:
|
||
|
|
port = FlaskServer._parse_port(port_part)
|
||
|
|
if port is None:
|
||
|
|
return "", None, "Port muss zwischen 1 und 65535 liegen."
|
||
|
|
return host_part, port, None
|
||
|
|
|
||
|
|
return raw, None, None
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _target_from_payload(payload: dict, prefix: str) -> tuple[TargetConnection | None, str | None]:
|
||
|
|
protocol = payload.get(f"{prefix}_protocol", "").strip().upper()
|
||
|
|
host_input = payload.get(f"{prefix}_host", "").strip()
|
||
|
|
host, parsed_port, host_error = FlaskServer._split_host_and_port(host_input)
|
||
|
|
share = payload.get(f"{prefix}_share", "").strip()
|
||
|
|
raw_subpath = payload.get(f"{prefix}_subpath", "")
|
||
|
|
subpath = normalize_subpath(raw_subpath)
|
||
|
|
username = payload.get(f"{prefix}_username", "").strip()
|
||
|
|
password = payload.get(f"{prefix}_password", "")
|
||
|
|
|
||
|
|
if protocol not in TARGET_PROTOCOLS:
|
||
|
|
return None, "Bitte ein gueltiges Protokoll auswaehlen."
|
||
|
|
if protocol != "LOCAL" and host_error:
|
||
|
|
return None, host_error
|
||
|
|
|
||
|
|
if protocol == "LOCAL":
|
||
|
|
if not raw_subpath.strip():
|
||
|
|
return None, "Fuer LOCAL muss ein gueltiger lokaler Pfad angegeben werden."
|
||
|
|
return (
|
||
|
|
TargetConnection(
|
||
|
|
protocol=protocol,
|
||
|
|
host="local",
|
||
|
|
port=None,
|
||
|
|
share="",
|
||
|
|
subpath=subpath,
|
||
|
|
username="",
|
||
|
|
password="",
|
||
|
|
),
|
||
|
|
None,
|
||
|
|
)
|
||
|
|
|
||
|
|
if protocol == "SMB":
|
||
|
|
if not all([host, share, username, password]):
|
||
|
|
return None, "SMB benoetigt Host, Share, Benutzer und Passwort."
|
||
|
|
return (
|
||
|
|
TargetConnection(
|
||
|
|
protocol=protocol,
|
||
|
|
host=host,
|
||
|
|
port=parsed_port,
|
||
|
|
share=share,
|
||
|
|
subpath=subpath,
|
||
|
|
username=username,
|
||
|
|
password=password,
|
||
|
|
),
|
||
|
|
None,
|
||
|
|
)
|
||
|
|
|
||
|
|
if protocol in {"FTP", "SFTP"}:
|
||
|
|
if not all([host, username, password]):
|
||
|
|
return None, f"{protocol} benoetigt Host, Benutzer und Passwort."
|
||
|
|
return (
|
||
|
|
TargetConnection(
|
||
|
|
protocol=protocol,
|
||
|
|
host=host,
|
||
|
|
port=parsed_port,
|
||
|
|
share=share,
|
||
|
|
subpath=subpath,
|
||
|
|
username=username,
|
||
|
|
password=password,
|
||
|
|
),
|
||
|
|
None,
|
||
|
|
)
|
||
|
|
|
||
|
|
return None, "Unbekanntes Protokoll."
|
||
|
|
|
||
|
|
def create_app(self) -> Flask:
|
||
|
|
app = Flask(__name__, template_folder="templates")
|
||
|
|
app.secret_key = os.getenv("APP_SECRET_KEY", secrets.token_hex(32))
|
||
|
|
app.config.update(
|
||
|
|
SESSION_COOKIE_HTTPONLY=True,
|
||
|
|
SESSION_COOKIE_SAMESITE="Lax",
|
||
|
|
)
|
||
|
|
|
||
|
|
@app.template_filter("fmt_bytes")
|
||
|
|
def fmt_bytes(value):
|
||
|
|
if value is None:
|
||
|
|
return "Noch kein Lauf"
|
||
|
|
try:
|
||
|
|
size = float(value)
|
||
|
|
except (TypeError, ValueError):
|
||
|
|
return "Unbekannt"
|
||
|
|
|
||
|
|
units = ["B", "KB", "MB", "GB", "TB"]
|
||
|
|
unit_idx = 0
|
||
|
|
while size >= 1024 and unit_idx < len(units) - 1:
|
||
|
|
size /= 1024
|
||
|
|
unit_idx += 1
|
||
|
|
return f"{size:.2f} {units[unit_idx]}"
|
||
|
|
|
||
|
|
@app.template_filter("fmt_dt")
|
||
|
|
def fmt_dt(value):
|
||
|
|
if not value:
|
||
|
|
return "Noch kein Lauf"
|
||
|
|
return str(value)
|
||
|
|
|
||
|
|
def parse_schedule_fields(form_data):
|
||
|
|
schedule_mode = form_data.get("schedule_mode", "daily").strip().lower()
|
||
|
|
schedule_time = form_data.get("schedule_time", "02:00").strip()
|
||
|
|
schedule_weekday = form_data.get("schedule_weekday", "1").strip()
|
||
|
|
schedule_day_of_month = form_data.get("schedule_day_of_month", "1").strip()
|
||
|
|
schedule_interval_raw = form_data.get("schedule_interval", "").strip()
|
||
|
|
schedule_cron = form_data.get("schedule_cron", "").strip()
|
||
|
|
|
||
|
|
if schedule_mode not in SCHEDULE_MODES:
|
||
|
|
return None, "Ungültiger Zeitplan-Modus."
|
||
|
|
|
||
|
|
schedule_interval = None
|
||
|
|
if schedule_mode in {"every_n_days", "every_n_weeks"}:
|
||
|
|
try:
|
||
|
|
schedule_interval = int(schedule_interval_raw)
|
||
|
|
except ValueError:
|
||
|
|
schedule_interval = None
|
||
|
|
if schedule_interval is None or schedule_interval < 1:
|
||
|
|
return None, "Intervall muss eine ganze Zahl >= 1 sein."
|
||
|
|
|
||
|
|
if schedule_mode == "custom" and not schedule_cron:
|
||
|
|
return None, "Bei benutzerdefiniertem Zeitplan ist ein CRON-Ausdruck erforderlich."
|
||
|
|
|
||
|
|
return {
|
||
|
|
"schedule_mode": schedule_mode,
|
||
|
|
"schedule_time": schedule_time,
|
||
|
|
"schedule_weekday": schedule_weekday,
|
||
|
|
"schedule_day_of_month": schedule_day_of_month,
|
||
|
|
"schedule_interval": schedule_interval,
|
||
|
|
"schedule_cron": schedule_cron,
|
||
|
|
}, None
|
||
|
|
|
||
|
|
def login_required(view_func):
|
||
|
|
@wraps(view_func)
|
||
|
|
def wrapped(*args, **kwargs):
|
||
|
|
if session.get("is_authenticated") is not True:
|
||
|
|
return redirect(url_for("login"))
|
||
|
|
return view_func(*args, **kwargs)
|
||
|
|
|
||
|
|
return wrapped
|
||
|
|
|
||
|
|
def admin_required(view_func):
|
||
|
|
@wraps(view_func)
|
||
|
|
def wrapped(*args, **kwargs):
|
||
|
|
if session.get("is_admin") is not True:
|
||
|
|
return redirect(url_for("dashboard"))
|
||
|
|
return view_func(*args, **kwargs)
|
||
|
|
|
||
|
|
return wrapped
|
||
|
|
|
||
|
|
@app.get("/")
|
||
|
|
def index():
|
||
|
|
if session.get("is_authenticated") is True:
|
||
|
|
return redirect(url_for("dashboard"))
|
||
|
|
return redirect(url_for("login"))
|
||
|
|
|
||
|
|
@app.route("/login", methods=["GET", "POST"])
|
||
|
|
def login():
|
||
|
|
error_message = ""
|
||
|
|
if request.method == "POST":
|
||
|
|
username = request.form.get("username", "")
|
||
|
|
password = request.form.get("password", "")
|
||
|
|
|
||
|
|
user = self.store.get_user(username)
|
||
|
|
if user and verify_password(password, user.password_hash):
|
||
|
|
session["is_authenticated"] = True
|
||
|
|
session["is_admin"] = user.is_admin
|
||
|
|
session["adminuser"] = user.username
|
||
|
|
return redirect(url_for("dashboard"))
|
||
|
|
error_message = "Anmeldung fehlgeschlagen."
|
||
|
|
|
||
|
|
return render_template("login.html", error_message=error_message)
|
||
|
|
|
||
|
|
@app.get("/dashboard")
|
||
|
|
@login_required
|
||
|
|
def dashboard():
|
||
|
|
return render_template(
|
||
|
|
"dashboard.html",
|
||
|
|
adminuser=session.get("adminuser", "admin"),
|
||
|
|
backups=self.store.list_backups(),
|
||
|
|
active_menu="dashboard",
|
||
|
|
)
|
||
|
|
|
||
|
|
@app.post("/backups/<int:backup_id>/run")
|
||
|
|
@login_required
|
||
|
|
@admin_required
|
||
|
|
def backup_run(backup_id: int):
|
||
|
|
backup = self.store.get_backup_edit_data(backup_id)
|
||
|
|
if backup is None:
|
||
|
|
flash("Backup nicht gefunden.", "error")
|
||
|
|
return redirect(url_for("dashboard"))
|
||
|
|
|
||
|
|
ok = self.store.mark_backup_run(backup_id)
|
||
|
|
if not ok:
|
||
|
|
flash("Backup nicht gefunden.", "error")
|
||
|
|
return redirect(url_for("dashboard"))
|
||
|
|
|
||
|
|
flash(
|
||
|
|
f"Manueller Lauf für '{backup['name']}' wurde gestartet und protokolliert.",
|
||
|
|
"success",
|
||
|
|
)
|
||
|
|
return redirect(url_for("dashboard"))
|
||
|
|
|
||
|
|
@app.route("/backups/<int:backup_id>/edit", methods=["GET", "POST"])
|
||
|
|
@login_required
|
||
|
|
@admin_required
|
||
|
|
def backup_edit(backup_id: int):
|
||
|
|
backup = self.store.get_backup_edit_data(backup_id)
|
||
|
|
if backup is None:
|
||
|
|
flash("Backup nicht gefunden.", "error")
|
||
|
|
return redirect(url_for("dashboard"))
|
||
|
|
|
||
|
|
if request.method == "POST":
|
||
|
|
name = request.form.get("name", "").strip()
|
||
|
|
target_pattern = request.form.get("target_pattern", "").strip()
|
||
|
|
compression_method = request.form.get("compression_method", "zip")
|
||
|
|
encryption_mode = request.form.get("encryption_mode", "none")
|
||
|
|
archive_password = request.form.get("archive_password", "")
|
||
|
|
archive_password_confirm = request.form.get("archive_password_confirm", "")
|
||
|
|
|
||
|
|
if not name or not target_pattern:
|
||
|
|
flash("Bitte Name und Ziel ausfüllen.", "error")
|
||
|
|
return redirect(url_for("backup_edit", backup_id=backup_id))
|
||
|
|
|
||
|
|
if compression_method not in COMPRESSION_METHODS:
|
||
|
|
flash("Ungültige Kompressionsmethode.", "error")
|
||
|
|
return redirect(url_for("backup_edit", backup_id=backup_id))
|
||
|
|
|
||
|
|
if encryption_mode not in ENCRYPTION_MODES:
|
||
|
|
flash("Ungültiger Verschlüsselungsmodus.", "error")
|
||
|
|
return redirect(url_for("backup_edit", backup_id=backup_id))
|
||
|
|
|
||
|
|
if encryption_mode == "password-aes256":
|
||
|
|
if archive_password and archive_password != archive_password_confirm:
|
||
|
|
flash("Passwort und Bestätigung stimmen nicht überein.", "error")
|
||
|
|
return redirect(url_for("backup_edit", backup_id=backup_id))
|
||
|
|
|
||
|
|
schedule, schedule_error = parse_schedule_fields(request.form)
|
||
|
|
if schedule_error:
|
||
|
|
flash(schedule_error, "error")
|
||
|
|
return redirect(url_for("backup_edit", backup_id=backup_id))
|
||
|
|
assert schedule is not None
|
||
|
|
|
||
|
|
updated = self.store.update_backup_edit_data(
|
||
|
|
backup_id=backup_id,
|
||
|
|
name=name,
|
||
|
|
target_pattern=target_pattern,
|
||
|
|
compression_method=compression_method,
|
||
|
|
encryption_mode=encryption_mode,
|
||
|
|
archive_password=archive_password,
|
||
|
|
schedule_mode=schedule["schedule_mode"],
|
||
|
|
schedule_time=schedule["schedule_time"],
|
||
|
|
schedule_weekday=schedule["schedule_weekday"],
|
||
|
|
schedule_day_of_month=schedule["schedule_day_of_month"],
|
||
|
|
schedule_interval=schedule["schedule_interval"],
|
||
|
|
schedule_cron=schedule["schedule_cron"],
|
||
|
|
)
|
||
|
|
|
||
|
|
if not updated:
|
||
|
|
flash("Backup konnte nicht gespeichert werden (Name eventuell doppelt).", "error")
|
||
|
|
return redirect(url_for("backup_edit", backup_id=backup_id))
|
||
|
|
|
||
|
|
flash("Backup wurde aktualisiert.", "success")
|
||
|
|
return redirect(url_for("dashboard"))
|
||
|
|
|
||
|
|
return render_template(
|
||
|
|
"backup_edit.html",
|
||
|
|
adminuser=session.get("adminuser", "admin"),
|
||
|
|
backup=backup,
|
||
|
|
compression_methods=COMPRESSION_METHODS,
|
||
|
|
encryption_modes=ENCRYPTION_MODES,
|
||
|
|
schedule_modes=SCHEDULE_MODES,
|
||
|
|
active_menu="dashboard",
|
||
|
|
)
|
||
|
|
|
||
|
|
@app.post("/backups/<int:backup_id>/delete")
|
||
|
|
@login_required
|
||
|
|
@admin_required
|
||
|
|
def backup_delete(backup_id: int):
|
||
|
|
deleted = self.store.delete_backup(backup_id)
|
||
|
|
if not deleted:
|
||
|
|
flash("Backup nicht gefunden.", "error")
|
||
|
|
else:
|
||
|
|
flash("Backup wurde gelöscht.", "success")
|
||
|
|
return redirect(url_for("dashboard"))
|
||
|
|
|
||
|
|
@app.post("/api/target/test")
|
||
|
|
@login_required
|
||
|
|
@admin_required
|
||
|
|
def api_target_test():
|
||
|
|
payload = request.get_json(silent=True) or {}
|
||
|
|
prefix = payload.get("prefix", "")
|
||
|
|
if prefix not in {"source", "destination"}:
|
||
|
|
return jsonify({"ok": False, "message": "Ungueltiger Bereich."}), 400
|
||
|
|
|
||
|
|
target, error = self._target_from_payload(payload, prefix)
|
||
|
|
if error:
|
||
|
|
return jsonify({"ok": False, "message": error}), 400
|
||
|
|
|
||
|
|
assert target is not None
|
||
|
|
ok, message = test_connection(target)
|
||
|
|
status = 200 if ok else 400
|
||
|
|
return jsonify({"ok": ok, "message": message}), status
|
||
|
|
|
||
|
|
@app.post("/api/target/browse-smb")
|
||
|
|
@login_required
|
||
|
|
@admin_required
|
||
|
|
def api_target_browse_smb():
|
||
|
|
payload = request.get_json(silent=True) or {}
|
||
|
|
prefix = payload.get("prefix", "")
|
||
|
|
if prefix not in {"source", "destination"}:
|
||
|
|
return jsonify({"ok": False, "message": "Ungueltiger Bereich."}), 400
|
||
|
|
|
||
|
|
target, error = self._target_from_payload(payload, prefix)
|
||
|
|
if error:
|
||
|
|
return jsonify({"ok": False, "message": error}), 400
|
||
|
|
if target is None or target.protocol != "SMB":
|
||
|
|
return jsonify({"ok": False, "message": "SMB-Browser nur mit SMB moeglich."}), 400
|
||
|
|
|
||
|
|
if not target.share:
|
||
|
|
return (
|
||
|
|
jsonify(
|
||
|
|
{
|
||
|
|
"ok": False,
|
||
|
|
"message": "Bitte zuerst eine SMB-Freigabe (Share) waehlen oder Shares laden.",
|
||
|
|
}
|
||
|
|
),
|
||
|
|
400,
|
||
|
|
)
|
||
|
|
|
||
|
|
ok, path_or_error, directories = browse_smb_directories(target)
|
||
|
|
if not ok:
|
||
|
|
return jsonify({"ok": False, "message": path_or_error}), 400
|
||
|
|
|
||
|
|
return jsonify(
|
||
|
|
{
|
||
|
|
"ok": True,
|
||
|
|
"path": path_or_error,
|
||
|
|
"entries": directories,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
@app.post("/api/target/smb-mkdir")
|
||
|
|
@login_required
|
||
|
|
@admin_required
|
||
|
|
def api_target_smb_mkdir():
|
||
|
|
payload = request.get_json(silent=True) or {}
|
||
|
|
prefix = payload.get("prefix", "")
|
||
|
|
if prefix != "destination":
|
||
|
|
return jsonify({"ok": False, "message": "Ordnererstellung ist nur fuer Destination erlaubt."}), 400
|
||
|
|
|
||
|
|
target, error = self._target_from_payload(payload, prefix)
|
||
|
|
if error:
|
||
|
|
return jsonify({"ok": False, "message": error}), 400
|
||
|
|
if target is None or target.protocol != "SMB":
|
||
|
|
return jsonify({"ok": False, "message": "Ordnererstellung nur mit SMB moeglich."}), 400
|
||
|
|
|
||
|
|
folder_name = payload.get("folder_name", "")
|
||
|
|
base_path = payload.get(f"{prefix}_subpath", "/")
|
||
|
|
ok, result = create_smb_directory(target, base_path, folder_name)
|
||
|
|
if not ok:
|
||
|
|
return jsonify({"ok": False, "message": result}), 400
|
||
|
|
return jsonify({"ok": True, "path": result, "message": "Ordner wurde erstellt."})
|
||
|
|
|
||
|
|
@app.post("/api/target/smb-shares")
|
||
|
|
@login_required
|
||
|
|
@admin_required
|
||
|
|
def api_target_smb_shares():
|
||
|
|
payload = request.get_json(silent=True) or {}
|
||
|
|
prefix = payload.get("prefix", "")
|
||
|
|
if prefix not in {"source", "destination"}:
|
||
|
|
return jsonify({"ok": False, "message": "Ungueltiger Bereich."}), 400
|
||
|
|
|
||
|
|
protocol = payload.get(f"{prefix}_protocol", "").strip().upper()
|
||
|
|
if protocol != "SMB":
|
||
|
|
return jsonify({"ok": False, "message": "Share-Liste nur mit SMB moeglich."}), 400
|
||
|
|
|
||
|
|
host_input = payload.get(f"{prefix}_host", "").strip()
|
||
|
|
host, parsed_port, host_error = self._split_host_and_port(host_input)
|
||
|
|
if host_error:
|
||
|
|
return jsonify({"ok": False, "message": host_error}), 400
|
||
|
|
|
||
|
|
username = payload.get(f"{prefix}_username", "").strip()
|
||
|
|
password = payload.get(f"{prefix}_password", "")
|
||
|
|
if not all([host, username, password]):
|
||
|
|
return (
|
||
|
|
jsonify(
|
||
|
|
{
|
||
|
|
"ok": False,
|
||
|
|
"message": "Host, Benutzer und Passwort sind zum Laden der Shares erforderlich.",
|
||
|
|
}
|
||
|
|
),
|
||
|
|
400,
|
||
|
|
)
|
||
|
|
|
||
|
|
target = TargetConnection(
|
||
|
|
protocol="SMB",
|
||
|
|
host=host,
|
||
|
|
port=parsed_port,
|
||
|
|
share="",
|
||
|
|
subpath="/",
|
||
|
|
username=username,
|
||
|
|
password=password,
|
||
|
|
)
|
||
|
|
ok, message, shares = list_smb_shares(target)
|
||
|
|
if not ok:
|
||
|
|
return jsonify({"ok": False, "message": message}), 400
|
||
|
|
return jsonify({"ok": True, "message": message, "shares": shares})
|
||
|
|
|
||
|
|
@app.route("/backups/new", methods=["GET", "POST"])
|
||
|
|
@login_required
|
||
|
|
@admin_required
|
||
|
|
def backup_new():
|
||
|
|
if request.method == "POST":
|
||
|
|
payload = request.form.to_dict(flat=True)
|
||
|
|
name = request.form.get("name", "").strip()
|
||
|
|
source_target, source_error = self._target_from_payload(payload, "source")
|
||
|
|
destination_target, destination_error = self._target_from_payload(payload, "destination")
|
||
|
|
|
||
|
|
source_entry_type = request.form.get("source_entry_type", "directory").strip().lower()
|
||
|
|
target_kind = request.form.get("target_kind", "folder").strip().lower()
|
||
|
|
target_pattern = request.form.get("target_pattern", "").strip()
|
||
|
|
compression_method = request.form.get("compression_method", "zip")
|
||
|
|
encryption_mode = request.form.get("encryption_mode", "none")
|
||
|
|
archive_password = request.form.get("archive_password", "")
|
||
|
|
archive_password_confirm = request.form.get("archive_password_confirm", "")
|
||
|
|
|
||
|
|
schedule, schedule_error = parse_schedule_fields(request.form)
|
||
|
|
if schedule_error:
|
||
|
|
flash(schedule_error, "error")
|
||
|
|
return redirect(url_for("backup_new"))
|
||
|
|
assert schedule is not None
|
||
|
|
|
||
|
|
required_values = [name, target_pattern]
|
||
|
|
if any(not item for item in required_values):
|
||
|
|
flash("Bitte alle Pflichtfelder ausfuellen.", "error")
|
||
|
|
return redirect(url_for("backup_new"))
|
||
|
|
|
||
|
|
if source_error:
|
||
|
|
flash(f"Source: {source_error}", "error")
|
||
|
|
return redirect(url_for("backup_new"))
|
||
|
|
if destination_error:
|
||
|
|
flash(f"Destination: {destination_error}", "error")
|
||
|
|
return redirect(url_for("backup_new"))
|
||
|
|
|
||
|
|
if compression_method not in COMPRESSION_METHODS:
|
||
|
|
flash("Ungueltige Kompressionsmethode.", "error")
|
||
|
|
return redirect(url_for("backup_new"))
|
||
|
|
|
||
|
|
if source_entry_type not in {"directory", "file"}:
|
||
|
|
flash("Ungueltiger Source-Typ (Datei/Ordner).", "error")
|
||
|
|
return redirect(url_for("backup_new"))
|
||
|
|
if target_kind not in {"folder", "file"}:
|
||
|
|
flash("Ungueltiger Target-Typ.", "error")
|
||
|
|
return redirect(url_for("backup_new"))
|
||
|
|
|
||
|
|
if encryption_mode not in ENCRYPTION_MODES:
|
||
|
|
flash("Ungueltiger Verschluesselungsmodus.", "error")
|
||
|
|
return redirect(url_for("backup_new"))
|
||
|
|
if encryption_mode == "none":
|
||
|
|
archive_password = ""
|
||
|
|
archive_password_confirm = ""
|
||
|
|
else:
|
||
|
|
if not archive_password:
|
||
|
|
flash("Bitte ein Archiv-Passwort setzen.", "error")
|
||
|
|
return redirect(url_for("backup_new"))
|
||
|
|
if archive_password != archive_password_confirm:
|
||
|
|
flash("Archiv-Passwort und Bestaetigung stimmen nicht ueberein.", "error")
|
||
|
|
return redirect(url_for("backup_new"))
|
||
|
|
|
||
|
|
assert source_target is not None
|
||
|
|
assert destination_target is not None
|
||
|
|
|
||
|
|
backup = BackupJob(
|
||
|
|
name=name,
|
||
|
|
source_protocol=source_target.protocol,
|
||
|
|
source_host=source_target.host,
|
||
|
|
source_port=source_target.port,
|
||
|
|
source_share=source_target.share,
|
||
|
|
source_subpath=source_target.subpath,
|
||
|
|
source_username=source_target.username,
|
||
|
|
source_password=source_target.password,
|
||
|
|
destination_protocol=destination_target.protocol,
|
||
|
|
destination_host=destination_target.host,
|
||
|
|
destination_port=destination_target.port,
|
||
|
|
destination_share=destination_target.share,
|
||
|
|
destination_subpath=destination_target.subpath,
|
||
|
|
destination_username=destination_target.username,
|
||
|
|
destination_password=destination_target.password,
|
||
|
|
source_entry_type=source_entry_type,
|
||
|
|
target_kind=target_kind,
|
||
|
|
target_pattern=target_pattern,
|
||
|
|
compression_method=compression_method,
|
||
|
|
encryption_mode=encryption_mode,
|
||
|
|
archive_password=archive_password,
|
||
|
|
schedule_mode=schedule["schedule_mode"],
|
||
|
|
schedule_time=schedule["schedule_time"],
|
||
|
|
schedule_weekday=schedule["schedule_weekday"],
|
||
|
|
schedule_day_of_month=schedule["schedule_day_of_month"],
|
||
|
|
schedule_interval=schedule["schedule_interval"],
|
||
|
|
schedule_cron=schedule["schedule_cron"],
|
||
|
|
)
|
||
|
|
|
||
|
|
created = self.store.create_backup(backup)
|
||
|
|
if not created:
|
||
|
|
flash("Backup-Name existiert bereits.", "error")
|
||
|
|
return redirect(url_for("backup_new"))
|
||
|
|
|
||
|
|
flash("Backup wurde gespeichert.", "success")
|
||
|
|
return redirect(url_for("dashboard"))
|
||
|
|
|
||
|
|
return render_template(
|
||
|
|
"backup_new.html",
|
||
|
|
adminuser=session.get("adminuser", "admin"),
|
||
|
|
compression_methods=COMPRESSION_METHODS,
|
||
|
|
encryption_modes=ENCRYPTION_MODES,
|
||
|
|
schedule_modes=SCHEDULE_MODES,
|
||
|
|
target_protocols=TARGET_PROTOCOLS,
|
||
|
|
active_menu="backups",
|
||
|
|
)
|
||
|
|
|
||
|
|
@app.route("/settings/server", methods=["GET", "POST"])
|
||
|
|
@login_required
|
||
|
|
@admin_required
|
||
|
|
def server_settings():
|
||
|
|
if request.method == "POST":
|
||
|
|
ip = request.form.get("ip", "").strip()
|
||
|
|
port = self._parse_port(request.form.get("port", ""))
|
||
|
|
debug = request.form.get("debug", "false").lower() == "true"
|
||
|
|
|
||
|
|
if not ip:
|
||
|
|
flash("IP darf nicht leer sein.", "error")
|
||
|
|
return redirect(url_for("server_settings"))
|
||
|
|
if port is None:
|
||
|
|
flash("Port muss zwischen 1 und 65535 liegen.", "error")
|
||
|
|
return redirect(url_for("server_settings"))
|
||
|
|
|
||
|
|
self.config = AppConfig(ip=ip, port=port, debug=debug)
|
||
|
|
self.store.save_config(self.config)
|
||
|
|
flash("Server-Settings gespeichert. Neustart des Services erforderlich.", "success")
|
||
|
|
return redirect(url_for("server_settings"))
|
||
|
|
|
||
|
|
return render_template(
|
||
|
|
"server_settings.html",
|
||
|
|
adminuser=session.get("adminuser", "admin"),
|
||
|
|
config=self.config,
|
||
|
|
active_menu="server",
|
||
|
|
)
|
||
|
|
|
||
|
|
@app.route("/settings/account", methods=["GET", "POST"])
|
||
|
|
@login_required
|
||
|
|
def account_settings():
|
||
|
|
current_username = session.get("adminuser", "")
|
||
|
|
if request.method == "POST":
|
||
|
|
new_username = request.form.get("new_username", "").strip()
|
||
|
|
current_password = request.form.get("current_password", "")
|
||
|
|
new_password = request.form.get("new_password", "")
|
||
|
|
confirm_password = request.form.get("confirm_password", "")
|
||
|
|
|
||
|
|
user = self.store.get_user(current_username)
|
||
|
|
if user is None:
|
||
|
|
flash("Benutzer wurde nicht gefunden.", "error")
|
||
|
|
return redirect(url_for("logout"))
|
||
|
|
|
||
|
|
if not verify_password(current_password, user.password_hash):
|
||
|
|
flash("Aktuelles Passwort ist falsch.", "error")
|
||
|
|
return redirect(url_for("account_settings"))
|
||
|
|
if not new_username:
|
||
|
|
flash("Neuer Benutzername darf nicht leer sein.", "error")
|
||
|
|
return redirect(url_for("account_settings"))
|
||
|
|
if not new_password:
|
||
|
|
flash("Neues Passwort darf nicht leer sein.", "error")
|
||
|
|
return redirect(url_for("account_settings"))
|
||
|
|
if new_password != confirm_password:
|
||
|
|
flash("Passwort-Bestaetigung stimmt nicht ueberein.", "error")
|
||
|
|
return redirect(url_for("account_settings"))
|
||
|
|
|
||
|
|
updated = self.store.update_user_credentials(
|
||
|
|
current_username=current_username,
|
||
|
|
new_username=new_username,
|
||
|
|
new_password_hash=hash_password(new_password),
|
||
|
|
)
|
||
|
|
if not updated:
|
||
|
|
flash("Benutzername existiert bereits.", "error")
|
||
|
|
return redirect(url_for("account_settings"))
|
||
|
|
|
||
|
|
session["adminuser"] = new_username
|
||
|
|
flash("Deine Zugangsdaten wurden aktualisiert.", "success")
|
||
|
|
return redirect(url_for("account_settings"))
|
||
|
|
|
||
|
|
return render_template(
|
||
|
|
"account_settings.html",
|
||
|
|
adminuser=current_username,
|
||
|
|
active_menu="account",
|
||
|
|
)
|
||
|
|
|
||
|
|
@app.route("/users", methods=["GET", "POST"])
|
||
|
|
@login_required
|
||
|
|
@admin_required
|
||
|
|
def users():
|
||
|
|
if request.method == "POST":
|
||
|
|
username = request.form.get("username", "").strip()
|
||
|
|
password = request.form.get("password", "")
|
||
|
|
password_confirm = request.form.get("password_confirm", "")
|
||
|
|
is_admin = request.form.get("is_admin") == "on"
|
||
|
|
|
||
|
|
if not username:
|
||
|
|
flash("Benutzername darf nicht leer sein.", "error")
|
||
|
|
return redirect(url_for("users"))
|
||
|
|
if not password:
|
||
|
|
flash("Passwort darf nicht leer sein.", "error")
|
||
|
|
return redirect(url_for("users"))
|
||
|
|
if password != password_confirm:
|
||
|
|
flash("Passwort-Bestaetigung stimmt nicht ueberein.", "error")
|
||
|
|
return redirect(url_for("users"))
|
||
|
|
|
||
|
|
created = self.store.create_user(
|
||
|
|
username=username,
|
||
|
|
password_hash=hash_password(password),
|
||
|
|
is_admin=is_admin,
|
||
|
|
)
|
||
|
|
if not created:
|
||
|
|
flash("Benutzername existiert bereits.", "error")
|
||
|
|
return redirect(url_for("users"))
|
||
|
|
|
||
|
|
flash("Neuer Benutzer wurde angelegt.", "success")
|
||
|
|
return redirect(url_for("users"))
|
||
|
|
|
||
|
|
return render_template(
|
||
|
|
"users.html",
|
||
|
|
adminuser=session.get("adminuser", "admin"),
|
||
|
|
users=self.store.list_users(),
|
||
|
|
active_menu="users",
|
||
|
|
)
|
||
|
|
|
||
|
|
@app.post("/logout")
|
||
|
|
def logout():
|
||
|
|
session.clear()
|
||
|
|
return redirect(url_for("login"))
|
||
|
|
|
||
|
|
return app
|
||
|
|
|
||
|
|
def run(self) -> None:
|
||
|
|
app = self.create_app()
|
||
|
|
app.run(host=self.config.ip, port=self.config.port, debug=self.config.debug)
|