Files
BackItUp/app/web_server.py
T
2026-07-29 17:36:22 +02:00

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)