feat: Weboberfläche mit Anmeldung, CSRF und vier Seiten
FastAPI + Jinja2 + lokal eingebundenes htmx, strikte CSP, Ersteinrichtung mit Einrichtungscode aus dem Log, Argon2, Sperre nach Fehlversuchen, Status-, Anbindungs-, Regel-, Protokoll- und Einstellungsseite, /healthz. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e2587551b0
commit
2b0b039aeb
@@ -25,6 +25,12 @@ class Settings(BaseSettings):
|
|||||||
host: str = "127.0.0.1"
|
host: str = "127.0.0.1"
|
||||||
port: int = Field(default=8080, ge=1, le=65535)
|
port: int = Field(default=8080, ge=1, le=65535)
|
||||||
|
|
||||||
|
# Hinter einem Reverse Proxy mit TLS auf true setzen (Cookie nur per HTTPS).
|
||||||
|
cookie_secure: bool = False
|
||||||
|
# IPs, deren X-Forwarded-For/-Proto übernommen wird (Reverse Proxy).
|
||||||
|
forwarded_allow_ips: str = "127.0.0.1"
|
||||||
|
session_hours: int = Field(default=12, ge=1, le=24 * 30)
|
||||||
|
|
||||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
||||||
log_format: Literal["logfmt", "json"] = "logfmt"
|
log_format: Literal["logfmt", "json"] = "logfmt"
|
||||||
|
|
||||||
@@ -37,6 +43,10 @@ class Settings(BaseSettings):
|
|||||||
def db_path(self) -> Path:
|
def db_path(self) -> Path:
|
||||||
return self.data_dir / "netmover.db"
|
return self.data_dir / "netmover.db"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def session_key_path(self) -> Path:
|
||||||
|
return self.data_dir / "session.key"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def secret_key_path(self) -> Path:
|
def secret_key_path(self) -> Path:
|
||||||
return self.data_dir / "secret.key"
|
return self.data_dir / "secret.key"
|
||||||
|
|||||||
@@ -572,7 +572,7 @@ class MountManager:
|
|||||||
self._notify("mount.error", m.name, msg)
|
self._notify("mount.error", m.name, msg)
|
||||||
raise MountUnavailable(msg)
|
raise MountUnavailable(msg)
|
||||||
|
|
||||||
def usage(self, mount_id: int) -> tuple[int, int] | None:
|
def usage(self, mount_id: int, timeout: float | None = None) -> tuple[int, int] | None:
|
||||||
"""(gesamt, frei) in Bytes – nur bei eingehängten Kernel-Mounts."""
|
"""(gesamt, frei) in Bytes – nur bei eingehängten Kernel-Mounts."""
|
||||||
m, _ = self._load(mount_id)
|
m, _ = self._load(mount_id)
|
||||||
if not m.type.is_kernel_mount:
|
if not m.type.is_kernel_mount:
|
||||||
@@ -581,7 +581,9 @@ class MountManager:
|
|||||||
if not self.backend.is_mounted(mp):
|
if not self.backend.is_mounted(mp):
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
st = run_with_timeout(lambda: os.statvfs(mp), self.probe_timeout, f"statvfs:{m.name}")
|
st = run_with_timeout(
|
||||||
|
lambda: os.statvfs(mp), timeout or self.probe_timeout, f"statvfs:{m.name}"
|
||||||
|
)
|
||||||
except (OSError, OperationTimeout):
|
except (OSError, OperationTimeout):
|
||||||
return None
|
return None
|
||||||
return st.f_blocks * st.f_frsize, st.f_bavail * st.f_frsize
|
return st.f_blocks * st.f_frsize, st.f_bavail * st.f_frsize
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Weboberfläche: FastAPI + Jinja2 + HTMX, serverseitig gerendert."""
|
||||||
@@ -0,0 +1,957 @@
|
|||||||
|
"""FastAPI-Anwendung: Middleware, Anmeldung, Seiten.
|
||||||
|
|
||||||
|
Seiten werden serverseitig gerendert (Jinja2). HTMX nur dort, wo es echten
|
||||||
|
Nutzen bringt: Testergebnisse, Probelauf-Tabelle, aufklappbare Protokolle und
|
||||||
|
das automatische Aktualisieren der Statusseite. Alle Aktionen funktionieren
|
||||||
|
auch als normale Formulare (Post/Redirect/Get).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from importlib.resources import files
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, FastAPI, Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
from starlette.datastructures import UploadFile
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
|
from netmover import __version__
|
||||||
|
from netmover.admin import (
|
||||||
|
AdminError,
|
||||||
|
MountInput,
|
||||||
|
RuleInput,
|
||||||
|
credential_for_test,
|
||||||
|
delete_mount,
|
||||||
|
mount_input_from_model,
|
||||||
|
parse_mount_form,
|
||||||
|
parse_rule_form,
|
||||||
|
rule_input_from_model,
|
||||||
|
save_mount,
|
||||||
|
save_rule,
|
||||||
|
)
|
||||||
|
from netmover.app_state import NetMover
|
||||||
|
from netmover.config import Settings, get_settings
|
||||||
|
from netmover.db import session_scope
|
||||||
|
from netmover.logging_setup import event, get_logger
|
||||||
|
from netmover.models import (
|
||||||
|
CollisionPolicy,
|
||||||
|
CredentialKind,
|
||||||
|
Mount,
|
||||||
|
MountState,
|
||||||
|
MountType,
|
||||||
|
PatternType,
|
||||||
|
Rule,
|
||||||
|
RuleAction,
|
||||||
|
Run,
|
||||||
|
RunItem,
|
||||||
|
RunStatus,
|
||||||
|
utcnow,
|
||||||
|
)
|
||||||
|
from netmover.mounting.backend import MountError
|
||||||
|
from netmover.mounting.report import CheckReport
|
||||||
|
from netmover.notify import Notification
|
||||||
|
from netmover.settings_store import AppSettings, NotifyMode, load_settings, save_settings
|
||||||
|
from netmover.transfer import apply_imported_settings, export_config, import_config
|
||||||
|
from netmover.web import auth
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
PAGE_SIZE = 50
|
||||||
|
STATE_LABELS = {
|
||||||
|
MountState.MOUNTED: "eingehängt",
|
||||||
|
MountState.UNMOUNTED: "ausgehängt",
|
||||||
|
MountState.DEGRADED: "gestört",
|
||||||
|
MountState.ERROR: "Fehler",
|
||||||
|
}
|
||||||
|
SFTP_STATE_LABELS = {
|
||||||
|
MountState.MOUNTED: "erreichbar",
|
||||||
|
MountState.UNMOUNTED: "nicht geprüft",
|
||||||
|
MountState.DEGRADED: "gestört",
|
||||||
|
MountState.ERROR: "nicht erreichbar",
|
||||||
|
}
|
||||||
|
RUN_LABELS = {
|
||||||
|
RunStatus.OK: "OK",
|
||||||
|
RunStatus.PARTIAL: "teilweise",
|
||||||
|
RunStatus.ERROR: "Fehler",
|
||||||
|
RunStatus.SKIPPED: "übersprungen",
|
||||||
|
RunStatus.RUNNING: "läuft",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -- Jinja-Filter -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _ago(value: datetime | None, now: datetime | None = None) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "–"
|
||||||
|
seconds = ((now or datetime.now(UTC)) - value).total_seconds()
|
||||||
|
future = seconds < 0
|
||||||
|
seconds = abs(seconds)
|
||||||
|
if seconds < 60:
|
||||||
|
text = f"{int(seconds)} s"
|
||||||
|
elif seconds < 3600:
|
||||||
|
text = f"{int(seconds // 60)} min"
|
||||||
|
elif seconds < 86400:
|
||||||
|
text = f"{seconds / 3600:.1f} h".replace(".", ",")
|
||||||
|
else:
|
||||||
|
text = f"{int(seconds // 86400)} Tg."
|
||||||
|
return f"in {text}" if future else f"vor {text}"
|
||||||
|
|
||||||
|
|
||||||
|
def _localdt(value: datetime | None) -> str:
|
||||||
|
return value.astimezone().strftime("%d.%m.%Y %H:%M:%S") if value else "–"
|
||||||
|
|
||||||
|
|
||||||
|
def _bytes(value: int | None) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "–"
|
||||||
|
units = ["B", "KB", "MB", "GB", "TB", "PB"]
|
||||||
|
n = float(value)
|
||||||
|
i = 0
|
||||||
|
while n >= 1024 and i < len(units) - 1:
|
||||||
|
n /= 1024
|
||||||
|
i += 1
|
||||||
|
return f"{n:.1f} {units[i]}".replace(".", ",") if i else f"{int(n)} B"
|
||||||
|
|
||||||
|
|
||||||
|
def _duration(start: datetime | None, end: datetime | None) -> str:
|
||||||
|
if start is None or end is None:
|
||||||
|
return "–"
|
||||||
|
s = (end - start).total_seconds()
|
||||||
|
return f"{s:.1f} s".replace(".", ",") if s < 60 else f"{int(s // 60)} min {int(s % 60)} s"
|
||||||
|
|
||||||
|
|
||||||
|
TEMPLATES = Jinja2Templates(directory=str(files("netmover.web") / "templates"))
|
||||||
|
TEMPLATES.env.filters.update(ago=_ago, localdt=_localdt, bytes=_bytes)
|
||||||
|
TEMPLATES.env.globals.update(
|
||||||
|
version=__version__,
|
||||||
|
duration=_duration,
|
||||||
|
STATE_LABELS=STATE_LABELS,
|
||||||
|
SFTP_STATE_LABELS=SFTP_STATE_LABELS,
|
||||||
|
RUN_LABELS=RUN_LABELS,
|
||||||
|
MountType=MountType,
|
||||||
|
MountState=MountState,
|
||||||
|
RunStatus=RunStatus,
|
||||||
|
PatternType=PatternType,
|
||||||
|
RuleAction=RuleAction,
|
||||||
|
CollisionPolicy=CollisionPolicy,
|
||||||
|
CredentialKind=CredentialKind,
|
||||||
|
NotifyMode=NotifyMode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def flash(request: Request, message: str, kind: str = "ok") -> None:
|
||||||
|
# Neu zuweisen statt anhängen: die Session erkennt nur Zuweisungen als Änderung.
|
||||||
|
request.session["flash"] = [*request.session.get("flash", []), [kind, message]]
|
||||||
|
|
||||||
|
|
||||||
|
def render(request: Request, name: str, status_code: int = 200, **ctx: Any) -> HTMLResponse:
|
||||||
|
flashes = request.session.pop("flash", []) if not name.startswith("partials/") else []
|
||||||
|
return TEMPLATES.TemplateResponse(
|
||||||
|
request,
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
"csrf": auth.csrf_token(request),
|
||||||
|
"flashes": flashes,
|
||||||
|
"logged_in": auth.is_logged_in(request),
|
||||||
|
"active": ctx.pop("active", ""),
|
||||||
|
**ctx,
|
||||||
|
},
|
||||||
|
status_code=status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def redirect(url: str) -> RedirectResponse:
|
||||||
|
return RedirectResponse(url, status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
def nm(request: Request) -> NetMover:
|
||||||
|
app_state: NetMover = request.app.state.nm
|
||||||
|
return app_state
|
||||||
|
|
||||||
|
|
||||||
|
def _form_dict(form: Any) -> dict[str, str]:
|
||||||
|
return {k: v for k, v in form.items() if isinstance(v, str)}
|
||||||
|
|
||||||
|
|
||||||
|
# -- Schutz für alle geschützten Routen ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def guard(request: Request) -> None:
|
||||||
|
state = nm(request)
|
||||||
|
if not auth.password_is_set(state.session_factory):
|
||||||
|
raise auth.LoginRequired(setup=True)
|
||||||
|
if not auth.is_logged_in(request):
|
||||||
|
raise auth.LoginRequired()
|
||||||
|
await auth.check_csrf(request)
|
||||||
|
|
||||||
|
|
||||||
|
protected = APIRouter(dependencies=[Depends(guard)])
|
||||||
|
public = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Öffentlich: Health, Einrichtung, Anmeldung --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@public.get("/healthz")
|
||||||
|
async def healthz(request: Request) -> JSONResponse:
|
||||||
|
health = await run_in_threadpool(nm(request).health)
|
||||||
|
body = {"status": "ok" if health.ok else "error", "problems": health.problems}
|
||||||
|
return JSONResponse(body, status_code=200 if health.ok else 503)
|
||||||
|
|
||||||
|
|
||||||
|
@public.get("/setup", response_class=HTMLResponse)
|
||||||
|
async def setup_form(request: Request) -> Response:
|
||||||
|
if auth.password_is_set(nm(request).session_factory):
|
||||||
|
return redirect("/login")
|
||||||
|
return render(request, "setup.html")
|
||||||
|
|
||||||
|
|
||||||
|
@public.post("/setup", response_class=HTMLResponse)
|
||||||
|
async def setup_submit(request: Request) -> Response:
|
||||||
|
await auth.check_csrf(request)
|
||||||
|
state = nm(request)
|
||||||
|
if auth.password_is_set(state.session_factory):
|
||||||
|
return redirect("/login")
|
||||||
|
form = await request.form()
|
||||||
|
password = str(form.get("password") or "")
|
||||||
|
problem = auth.password_problem(password, str(form.get("confirm") or ""))
|
||||||
|
if not request.app.state.setup_code.matches(str(form.get("code") or "")):
|
||||||
|
problem = (
|
||||||
|
"Einrichtungscode stimmt nicht – er steht im Log des Dienstes (journalctl -u netmover)."
|
||||||
|
)
|
||||||
|
if problem:
|
||||||
|
return render(request, "setup.html", status_code=400, error=problem)
|
||||||
|
await run_in_threadpool(auth.set_password, state.session_factory, password)
|
||||||
|
auth.login_session(request)
|
||||||
|
event(log, "auth.setup_done")
|
||||||
|
flash(request, "Passwort gesetzt. Willkommen bei NetMover!")
|
||||||
|
return redirect("/")
|
||||||
|
|
||||||
|
|
||||||
|
@public.get("/login", response_class=HTMLResponse)
|
||||||
|
async def login_form(request: Request) -> Response:
|
||||||
|
if not auth.password_is_set(nm(request).session_factory):
|
||||||
|
return redirect("/setup")
|
||||||
|
return render(request, "login.html")
|
||||||
|
|
||||||
|
|
||||||
|
@public.post("/login", response_class=HTMLResponse)
|
||||||
|
async def login_submit(request: Request) -> Response:
|
||||||
|
await auth.check_csrf(request)
|
||||||
|
throttle: auth.LoginThrottle = request.app.state.throttle
|
||||||
|
client = request.client.host if request.client else "?"
|
||||||
|
wait = throttle.locked_for(client)
|
||||||
|
if wait:
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"login.html",
|
||||||
|
status_code=429,
|
||||||
|
error=f"Zu viele Fehlversuche – bitte {math.ceil(wait / 60)} min warten.",
|
||||||
|
)
|
||||||
|
form = await request.form()
|
||||||
|
ok = await run_in_threadpool(
|
||||||
|
auth.verify_password, nm(request).session_factory, str(form.get("password") or "")
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
throttle.failure(client)
|
||||||
|
event(log, "auth.login_failed", level=30, client=client)
|
||||||
|
return render(request, "login.html", status_code=401, error="Passwort falsch.")
|
||||||
|
throttle.success(client)
|
||||||
|
auth.login_session(request)
|
||||||
|
event(log, "auth.login", client=client)
|
||||||
|
target = request.query_params.get("next", "/")
|
||||||
|
return redirect(target if target.startswith("/") and not target.startswith("//") else "/")
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/logout")
|
||||||
|
async def logout(request: Request) -> Response:
|
||||||
|
request.session.clear()
|
||||||
|
return redirect("/login")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Status -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MountView:
|
||||||
|
m: Mount
|
||||||
|
usage: tuple[int, int] | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RuleView:
|
||||||
|
r: Rule
|
||||||
|
last_run: Run | None
|
||||||
|
next_run: datetime | None
|
||||||
|
running_since: datetime | None
|
||||||
|
stale: bool
|
||||||
|
target: str
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_target(r: Rule) -> str:
|
||||||
|
src = f"{r.src_mount.name}:/{r.src_subpath}" if r.src_subpath else f"{r.src_mount.name}:/"
|
||||||
|
if r.dst_mount is None:
|
||||||
|
dst = r.dst_path
|
||||||
|
else:
|
||||||
|
dst = f"{r.dst_mount.name}:/{r.dst_path}" if r.dst_path else f"{r.dst_mount.name}:/"
|
||||||
|
return f"{src} → {dst}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _status_context(request: Request) -> dict[str, Any]:
|
||||||
|
state = nm(request)
|
||||||
|
health = await run_in_threadpool(state.health)
|
||||||
|
with state.session_factory() as s:
|
||||||
|
mounts = s.scalars(select(Mount).order_by(Mount.name)).all()
|
||||||
|
rules = s.scalars(select(Rule).order_by(Rule.name)).all()
|
||||||
|
last_runs = {
|
||||||
|
run.rule_id: run
|
||||||
|
for run in s.scalars(
|
||||||
|
select(Run).where(
|
||||||
|
Run.id.in_(
|
||||||
|
select(func.max(Run.id))
|
||||||
|
.where(Run.status != RunStatus.SKIPPED)
|
||||||
|
.group_by(Run.rule_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
rule_views = [
|
||||||
|
RuleView(
|
||||||
|
r,
|
||||||
|
last_runs.get(r.id),
|
||||||
|
state.scheduler.next_run(r.id),
|
||||||
|
state.runner.running_since(r.id),
|
||||||
|
r.id in health.stale_rules,
|
||||||
|
_rule_target(r),
|
||||||
|
)
|
||||||
|
for r in rules
|
||||||
|
]
|
||||||
|
for m in mounts:
|
||||||
|
s.expunge(m)
|
||||||
|
|
||||||
|
async def usage(m: Mount) -> tuple[int, int] | None:
|
||||||
|
if m.state is not MountState.MOUNTED or not m.type.is_kernel_mount:
|
||||||
|
return None
|
||||||
|
return await run_in_threadpool(state.mounts.usage, m.id, 2.0)
|
||||||
|
|
||||||
|
usages = await asyncio.gather(*(usage(m) for m in mounts))
|
||||||
|
return {
|
||||||
|
"health": health,
|
||||||
|
"mounts": [MountView(m, u) for m, u in zip(mounts, usages, strict=True)],
|
||||||
|
"rules": rule_views,
|
||||||
|
"now": utcnow(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@protected.get("/", response_class=HTMLResponse)
|
||||||
|
async def status_page(request: Request) -> Response:
|
||||||
|
return render(request, "status.html", active="status", **await _status_context(request))
|
||||||
|
|
||||||
|
|
||||||
|
@protected.get("/partials/status", response_class=HTMLResponse)
|
||||||
|
async def status_partial(request: Request) -> Response:
|
||||||
|
return render(request, "partials/status_body.html", **await _status_context(request))
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/rules/{rule_id}/run")
|
||||||
|
async def rule_run_now(request: Request, rule_id: int) -> Response:
|
||||||
|
state = nm(request)
|
||||||
|
task = asyncio.create_task(state.scheduler.run_now(rule_id))
|
||||||
|
request.app.state.tasks.add(task)
|
||||||
|
task.add_done_callback(request.app.state.tasks.discard)
|
||||||
|
flash(request, "Lauf gestartet – das Ergebnis erscheint gleich im Protokoll.")
|
||||||
|
return redirect(request.headers.get("referer") or "/")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Anbindungen ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@protected.get("/mounts", response_class=HTMLResponse)
|
||||||
|
async def mounts_page(request: Request) -> Response:
|
||||||
|
with nm(request).session_factory() as s:
|
||||||
|
mounts = s.scalars(select(Mount).order_by(Mount.name)).all()
|
||||||
|
reports = {
|
||||||
|
m.id: CheckReport(**_report_data(m.last_test_result))
|
||||||
|
for m in mounts
|
||||||
|
if m.last_test_result
|
||||||
|
}
|
||||||
|
pubkeys = {
|
||||||
|
m.id: m.credential.public_key
|
||||||
|
for m in mounts
|
||||||
|
if m.credential is not None and m.credential.public_key
|
||||||
|
}
|
||||||
|
return render(
|
||||||
|
request, "mounts.html", active="mounts", mounts=mounts, reports=reports, pubkeys=pubkeys
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _report_data(raw: str | None) -> dict[str, Any]:
|
||||||
|
from netmover.mounting.report import CheckStep
|
||||||
|
|
||||||
|
data: dict[str, Any] = json.loads(raw or "{}")
|
||||||
|
data.pop("roles", None)
|
||||||
|
data.pop("ok", None)
|
||||||
|
data["steps"] = [CheckStep(**st) for st in data.get("steps", [])]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _mount_form(
|
||||||
|
request: Request,
|
||||||
|
data: MountInput,
|
||||||
|
errors: dict[str, str],
|
||||||
|
mount: Mount | None = None,
|
||||||
|
status_code: int = 200,
|
||||||
|
) -> Response:
|
||||||
|
pub = mount.credential.public_key if mount is not None and mount.credential else None
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"mount_form.html",
|
||||||
|
status_code=status_code,
|
||||||
|
active="mounts",
|
||||||
|
d=data,
|
||||||
|
errors=errors,
|
||||||
|
mount=mount,
|
||||||
|
public_key=pub,
|
||||||
|
has_secret=bool(mount is not None and mount.credential is not None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@protected.get("/mounts/new", response_class=HTMLResponse)
|
||||||
|
async def mount_new(request: Request) -> Response:
|
||||||
|
return _mount_form(request, MountInput(), {})
|
||||||
|
|
||||||
|
|
||||||
|
@protected.get("/mounts/{mount_id}/edit", response_class=HTMLResponse)
|
||||||
|
async def mount_edit(request: Request, mount_id: int) -> Response:
|
||||||
|
with nm(request).session_factory() as s:
|
||||||
|
m = s.get(Mount, mount_id)
|
||||||
|
if m is None:
|
||||||
|
return redirect("/mounts")
|
||||||
|
return _mount_form(request, mount_input_from_model(m), {}, m)
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/mounts/new", response_class=HTMLResponse)
|
||||||
|
@protected.post("/mounts/{mount_id}/edit", response_class=HTMLResponse)
|
||||||
|
async def mount_save(request: Request, mount_id: int | None = None) -> Response:
|
||||||
|
state = nm(request)
|
||||||
|
data, errors = parse_mount_form(_form_dict(await request.form()))
|
||||||
|
with session_scope(state.session_factory) as s:
|
||||||
|
existing = s.get(Mount, mount_id) if mount_id is not None else None
|
||||||
|
if not errors:
|
||||||
|
try:
|
||||||
|
m, errors = save_mount(s, state.secrets, state.mounts, data, mount_id)
|
||||||
|
except AdminError as exc:
|
||||||
|
errors = {"_": str(exc)}
|
||||||
|
m = None
|
||||||
|
else:
|
||||||
|
m = None
|
||||||
|
if errors or m is None:
|
||||||
|
s.rollback()
|
||||||
|
existing = s.get(Mount, mount_id) if mount_id is not None else None
|
||||||
|
return _mount_form(request, data, errors, existing, status_code=400)
|
||||||
|
saved_id, name = m.id, m.name
|
||||||
|
is_sftp_key = m.type is MountType.SFTP and data.auth is CredentialKind.SSH_KEY
|
||||||
|
flash(request, f"Anbindung „{name}“ gespeichert.")
|
||||||
|
if is_sftp_key:
|
||||||
|
flash(
|
||||||
|
request,
|
||||||
|
"Öffentlichen Schlüssel beim Zielserver in authorized_keys eintragen, dann „Testen“.",
|
||||||
|
"info",
|
||||||
|
)
|
||||||
|
state.scheduler.sync()
|
||||||
|
return redirect(f"/mounts#mount-{saved_id}")
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/mounts/test", response_class=HTMLResponse)
|
||||||
|
async def mount_test_form(request: Request) -> Response:
|
||||||
|
"""„Testen“ im Formular – mit den (noch nicht gespeicherten) Eingaben."""
|
||||||
|
state = nm(request)
|
||||||
|
form = _form_dict(await request.form())
|
||||||
|
data, errors = parse_mount_form(form)
|
||||||
|
if errors:
|
||||||
|
return render(request, "partials/check_result.html", errors=errors, report=None)
|
||||||
|
existing_id = int(form["mount_id"]) if form.get("mount_id", "").isdigit() else None
|
||||||
|
with state.session_factory() as s:
|
||||||
|
existing = s.get(Mount, existing_id) if existing_id else None
|
||||||
|
cred = credential_for_test(data, existing, state.secrets)
|
||||||
|
cfg = data.config()
|
||||||
|
if data.type is MountType.SFTP and existing is not None:
|
||||||
|
cfg = dataclasses.replace(cfg, host_key=existing.host_key)
|
||||||
|
report = await run_in_threadpool(state.mounts.check, cfg, cred, None)
|
||||||
|
return render(request, "partials/check_result.html", report=report, errors={})
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/mounts/{mount_id}/test", response_class=HTMLResponse)
|
||||||
|
async def mount_test(request: Request, mount_id: int) -> Response:
|
||||||
|
state = nm(request)
|
||||||
|
with state.session_factory() as s:
|
||||||
|
m = s.get(Mount, mount_id)
|
||||||
|
if m is None:
|
||||||
|
return HTMLResponse("Anbindung existiert nicht.", status_code=404)
|
||||||
|
data = mount_input_from_model(m)
|
||||||
|
cred = credential_for_test(data, m, state.secrets)
|
||||||
|
from netmover.mounting.manager import MountConfig
|
||||||
|
|
||||||
|
cfg = MountConfig.from_model(m)
|
||||||
|
report = await run_in_threadpool(state.mounts.check, cfg, cred, mount_id)
|
||||||
|
return render(request, "partials/check_result.html", report=report, errors={})
|
||||||
|
|
||||||
|
|
||||||
|
async def _mount_action(
|
||||||
|
request: Request, mount_id: int, fn: Callable[[int], None], ok: str
|
||||||
|
) -> Response:
|
||||||
|
try:
|
||||||
|
await run_in_threadpool(fn, mount_id)
|
||||||
|
flash(request, ok)
|
||||||
|
except (MountError, LookupError) as exc:
|
||||||
|
flash(request, str(exc), "error")
|
||||||
|
return redirect(f"/mounts#mount-{mount_id}")
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/mounts/{mount_id}/mount")
|
||||||
|
async def mount_mount(request: Request, mount_id: int) -> Response:
|
||||||
|
return await _mount_action(request, mount_id, nm(request).mounts.mount, "Eingehängt.")
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/mounts/{mount_id}/unmount")
|
||||||
|
async def mount_unmount(request: Request, mount_id: int) -> Response:
|
||||||
|
return await _mount_action(request, mount_id, nm(request).mounts.unmount, "Ausgehängt.")
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/mounts/{mount_id}/delete")
|
||||||
|
async def mount_delete(request: Request, mount_id: int) -> Response:
|
||||||
|
state = nm(request)
|
||||||
|
|
||||||
|
def work() -> None:
|
||||||
|
with session_scope(state.session_factory) as s:
|
||||||
|
delete_mount(s, state.mounts, mount_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await run_in_threadpool(work)
|
||||||
|
flash(request, "Anbindung gelöscht.")
|
||||||
|
except (AdminError, MountError) as exc:
|
||||||
|
flash(request, str(exc), "error")
|
||||||
|
return redirect("/mounts")
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/mounts/{mount_id}/reset-hostkey")
|
||||||
|
async def mount_reset_hostkey(request: Request, mount_id: int) -> Response:
|
||||||
|
with session_scope(nm(request).session_factory) as s:
|
||||||
|
m = s.get(Mount, mount_id)
|
||||||
|
if m is not None:
|
||||||
|
m.host_key = None
|
||||||
|
m.state = MountState.UNMOUNTED
|
||||||
|
flash(
|
||||||
|
request,
|
||||||
|
"Hostschlüssel zurückgesetzt – beim nächsten „Testen“ wird er neu festgehalten.",
|
||||||
|
"info",
|
||||||
|
)
|
||||||
|
return redirect(f"/mounts#mount-{mount_id}")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Regeln -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_form(
|
||||||
|
request: Request,
|
||||||
|
d: RuleInput,
|
||||||
|
errors: dict[str, str],
|
||||||
|
rule_id: int | None,
|
||||||
|
status_code: int = 200,
|
||||||
|
) -> Response:
|
||||||
|
with nm(request).session_factory() as s:
|
||||||
|
mounts = s.scalars(select(Mount).order_by(Mount.name)).all()
|
||||||
|
for m in mounts:
|
||||||
|
s.expunge(m)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"rule_form.html",
|
||||||
|
status_code=status_code,
|
||||||
|
active="rules",
|
||||||
|
d=d,
|
||||||
|
errors=errors,
|
||||||
|
rule_id=rule_id,
|
||||||
|
src_mounts=[m for m in mounts if m.type.is_kernel_mount],
|
||||||
|
src_options=[("", "– wählen –")]
|
||||||
|
+ [(m.id, m.name) for m in mounts if m.type.is_kernel_mount],
|
||||||
|
dst_options=[("", "– wählen –")] + [(m.id, f"{m.name} ({m.type.value})") for m in mounts],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@protected.get("/rules", response_class=HTMLResponse)
|
||||||
|
async def rules_page(request: Request) -> Response:
|
||||||
|
ctx = await _status_context(request)
|
||||||
|
return render(request, "rules.html", active="rules", rules=ctx["rules"], now=ctx["now"])
|
||||||
|
|
||||||
|
|
||||||
|
@protected.get("/rules/new", response_class=HTMLResponse)
|
||||||
|
async def rule_new(request: Request) -> Response:
|
||||||
|
return _rule_form(request, RuleInput(), {}, None)
|
||||||
|
|
||||||
|
|
||||||
|
@protected.get("/rules/{rule_id}/edit", response_class=HTMLResponse)
|
||||||
|
async def rule_edit(request: Request, rule_id: int) -> Response:
|
||||||
|
with nm(request).session_factory() as s:
|
||||||
|
r = s.get(Rule, rule_id)
|
||||||
|
if r is None:
|
||||||
|
return redirect("/rules")
|
||||||
|
d = rule_input_from_model(r)
|
||||||
|
return _rule_form(request, d, {}, rule_id)
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/rules/new", response_class=HTMLResponse)
|
||||||
|
@protected.post("/rules/{rule_id}/edit", response_class=HTMLResponse)
|
||||||
|
async def rule_save(request: Request, rule_id: int | None = None) -> Response:
|
||||||
|
state = nm(request)
|
||||||
|
form = _form_dict(await request.form())
|
||||||
|
with session_scope(state.session_factory) as s:
|
||||||
|
d, errors = parse_rule_form(s, form)
|
||||||
|
r = None
|
||||||
|
if not errors:
|
||||||
|
try:
|
||||||
|
r, errors = save_rule(s, d, rule_id)
|
||||||
|
except AdminError as exc:
|
||||||
|
errors = {"_": str(exc)}
|
||||||
|
if errors or r is None:
|
||||||
|
s.rollback()
|
||||||
|
return _rule_form(request, d, errors, rule_id, status_code=400)
|
||||||
|
name = r.name
|
||||||
|
state.scheduler.sync()
|
||||||
|
flash(request, f"Regel „{name}“ gespeichert.")
|
||||||
|
return redirect("/rules")
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/rules/{rule_id}/toggle")
|
||||||
|
async def rule_toggle(request: Request, rule_id: int) -> Response:
|
||||||
|
state = nm(request)
|
||||||
|
with session_scope(state.session_factory) as s:
|
||||||
|
r = s.get(Rule, rule_id)
|
||||||
|
if r is not None:
|
||||||
|
r.enabled = not r.enabled
|
||||||
|
flash(request, f"Regel „{r.name}“ {'aktiviert' if r.enabled else 'deaktiviert'}.")
|
||||||
|
state.scheduler.sync()
|
||||||
|
return redirect("/rules")
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/rules/{rule_id}/delete")
|
||||||
|
async def rule_delete(request: Request, rule_id: int) -> Response:
|
||||||
|
state = nm(request)
|
||||||
|
with session_scope(state.session_factory) as s:
|
||||||
|
r = s.get(Rule, rule_id)
|
||||||
|
if r is not None:
|
||||||
|
s.delete(r)
|
||||||
|
flash(request, f"Regel „{r.name}“ gelöscht.")
|
||||||
|
state.scheduler.sync()
|
||||||
|
return redirect("/rules")
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/rules/{rule_id}/dryrun", response_class=HTMLResponse)
|
||||||
|
async def rule_dryrun(request: Request, rule_id: int) -> Response:
|
||||||
|
state = nm(request)
|
||||||
|
run_id = await state.scheduler.run_now(rule_id, dry_run=True)
|
||||||
|
with state.session_factory() as s:
|
||||||
|
run = s.get(Run, run_id) if run_id else None
|
||||||
|
items = s.scalars(select(RunItem).where(RunItem.run_id == run_id)).all() if run_id else []
|
||||||
|
return render(request, "partials/dryrun.html", run=run, items=items)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Protokoll --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@protected.get("/log", response_class=HTMLResponse)
|
||||||
|
async def log_page(request: Request) -> Response:
|
||||||
|
q = request.query_params
|
||||||
|
rule_id = int(q["rule"]) if q.get("rule", "").isdigit() else None
|
||||||
|
status = q.get("status") or ""
|
||||||
|
active_only = q.get("all") != "1"
|
||||||
|
page = max(1, int(q["page"])) if q.get("page", "").isdigit() else 1
|
||||||
|
with nm(request).session_factory() as s:
|
||||||
|
stmt = select(Run, Rule.name).join(Rule, Rule.id == Run.rule_id)
|
||||||
|
if rule_id:
|
||||||
|
stmt = stmt.where(Run.rule_id == rule_id)
|
||||||
|
if status in {st.value for st in RunStatus}:
|
||||||
|
stmt = stmt.where(Run.status == RunStatus(status))
|
||||||
|
if active_only:
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(Run.seen > 0, Run.status != RunStatus.OK, Run.message.is_not(None))
|
||||||
|
)
|
||||||
|
total = s.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||||
|
rows = s.execute(
|
||||||
|
stmt.order_by(Run.started_at.desc(), Run.id.desc())
|
||||||
|
.offset((page - 1) * PAGE_SIZE)
|
||||||
|
.limit(PAGE_SIZE)
|
||||||
|
).all()
|
||||||
|
rules = s.execute(select(Rule.id, Rule.name).order_by(Rule.name)).all()
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"log.html",
|
||||||
|
active="log",
|
||||||
|
rows=rows,
|
||||||
|
rules=rules,
|
||||||
|
rule_id=rule_id,
|
||||||
|
status=status,
|
||||||
|
active_only=active_only,
|
||||||
|
page=page,
|
||||||
|
pages=max(1, math.ceil(total / PAGE_SIZE)),
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@protected.get("/log/{run_id}/items", response_class=HTMLResponse)
|
||||||
|
async def log_items(request: Request, run_id: int) -> Response:
|
||||||
|
with nm(request).session_factory() as s:
|
||||||
|
items = s.scalars(
|
||||||
|
select(RunItem).where(RunItem.run_id == run_id).order_by(RunItem.id)
|
||||||
|
).all()
|
||||||
|
run = s.get(Run, run_id)
|
||||||
|
return render(request, "partials/run_items.html", items=items, run=run)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Einstellungen ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@protected.get("/settings", response_class=HTMLResponse)
|
||||||
|
async def settings_page(request: Request) -> Response:
|
||||||
|
cfg = load_settings(nm(request).session_factory)
|
||||||
|
return render(request, "settings.html", active="settings", cfg=cfg, errors={})
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/settings", response_class=HTMLResponse)
|
||||||
|
async def settings_save(request: Request) -> Response:
|
||||||
|
state = nm(request)
|
||||||
|
form = _form_dict(await request.form())
|
||||||
|
cfg = load_settings(state.session_factory)
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
try:
|
||||||
|
cfg.retention_days = int(form.get("retention_days") or 30)
|
||||||
|
if not 1 <= cfg.retention_days <= 3650:
|
||||||
|
errors["retention_days"] = "Zwischen 1 und 3650 Tagen."
|
||||||
|
cfg.notify_after = int(form.get("notify_after") or 3)
|
||||||
|
if cfg.notify_after < 1:
|
||||||
|
errors["notify_after"] = "Mindestens 1."
|
||||||
|
except ValueError:
|
||||||
|
errors["retention_days"] = "Bitte ganze Zahlen angeben."
|
||||||
|
try:
|
||||||
|
cfg.notify_mode = NotifyMode(form.get("notify_mode") or "off")
|
||||||
|
except ValueError:
|
||||||
|
errors["notify_mode"] = "Ungültig."
|
||||||
|
url = (form.get("webhook_url") or "").strip()
|
||||||
|
if url != auth_mask(cfg.webhook_url): # Maske unverändert = URL behalten
|
||||||
|
if url and not url.startswith(("http://", "https://")):
|
||||||
|
errors["webhook_url"] = "URL muss mit http:// oder https:// beginnen."
|
||||||
|
cfg.webhook_url = url
|
||||||
|
cfg.notify_recovery = form.get("notify_recovery") == "on"
|
||||||
|
cfg.webhook_template = form.get("webhook_template") or cfg.webhook_template
|
||||||
|
cfg.webhook_content_type = (form.get("webhook_content_type") or "application/json").strip()
|
||||||
|
if errors:
|
||||||
|
return render(
|
||||||
|
request, "settings.html", status_code=400, active="settings", cfg=cfg, errors=errors
|
||||||
|
)
|
||||||
|
save_settings(state.session_factory, cfg)
|
||||||
|
flash(request, "Einstellungen gespeichert.")
|
||||||
|
return redirect("/settings")
|
||||||
|
|
||||||
|
|
||||||
|
def auth_mask(url: str) -> str:
|
||||||
|
"""Webhook-URL nur gekürzt anzeigen – sie enthält oft ein Token."""
|
||||||
|
if not url:
|
||||||
|
return ""
|
||||||
|
head, _, _rest = url.partition("://")
|
||||||
|
host = _rest.split("/", 1)[0]
|
||||||
|
return f"{head}://{host}/••••••"
|
||||||
|
|
||||||
|
|
||||||
|
TEMPLATES.env.globals["mask_url"] = auth_mask
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/settings/webhook-test", response_class=HTMLResponse)
|
||||||
|
async def settings_webhook_test(request: Request) -> Response:
|
||||||
|
state = nm(request)
|
||||||
|
cfg = load_settings(state.session_factory)
|
||||||
|
if not cfg.webhook_url:
|
||||||
|
return HTMLResponse('<p class="msg error">Keine Webhook-URL gespeichert.</p>')
|
||||||
|
from netmover.notify import http_sender
|
||||||
|
from netmover.notify import render as render_body
|
||||||
|
|
||||||
|
body = render_body(
|
||||||
|
cfg.webhook_template,
|
||||||
|
cfg.webhook_content_type,
|
||||||
|
Notification("test", "NetMover: Testnachricht", "Webhook funktioniert."),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await run_in_threadpool(http_sender, cfg.webhook_url, cfg.webhook_content_type, body)
|
||||||
|
except Exception as exc:
|
||||||
|
return HTMLResponse(f'<p class="msg error">Fehlgeschlagen: {type(exc).__name__}</p>')
|
||||||
|
return HTMLResponse('<p class="msg ok">Testnachricht gesendet.</p>')
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/settings/password", response_class=HTMLResponse)
|
||||||
|
async def settings_password(request: Request) -> Response:
|
||||||
|
state = nm(request)
|
||||||
|
form = _form_dict(await request.form())
|
||||||
|
if not await run_in_threadpool(
|
||||||
|
auth.verify_password, state.session_factory, form.get("current", "")
|
||||||
|
):
|
||||||
|
flash(request, "Aktuelles Passwort falsch.", "error")
|
||||||
|
return redirect("/settings")
|
||||||
|
problem = auth.password_problem(form.get("password", ""), form.get("confirm", ""))
|
||||||
|
if problem:
|
||||||
|
flash(request, problem, "error")
|
||||||
|
return redirect("/settings")
|
||||||
|
await run_in_threadpool(auth.set_password, state.session_factory, form["password"])
|
||||||
|
auth.login_session(request)
|
||||||
|
flash(request, "Passwort geändert.")
|
||||||
|
return redirect("/settings")
|
||||||
|
|
||||||
|
|
||||||
|
@protected.get("/settings/export")
|
||||||
|
async def settings_export(request: Request) -> Response:
|
||||||
|
state = nm(request)
|
||||||
|
with state.session_factory() as s:
|
||||||
|
data = export_config(s, load_settings(state.session_factory))
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d-%H%M")
|
||||||
|
return Response(
|
||||||
|
json.dumps(data, ensure_ascii=False, indent=2),
|
||||||
|
media_type="application/json",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="netmover-{stamp}.json"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/settings/import")
|
||||||
|
async def settings_import(request: Request) -> Response:
|
||||||
|
state = nm(request)
|
||||||
|
form = await request.form()
|
||||||
|
upload = form.get("file")
|
||||||
|
if not isinstance(upload, UploadFile):
|
||||||
|
flash(request, "Keine Datei ausgewählt.", "error")
|
||||||
|
return redirect("/settings")
|
||||||
|
raw = await upload.read(2 * 1024 * 1024 + 1)
|
||||||
|
if len(raw) > 2 * 1024 * 1024:
|
||||||
|
flash(request, "Datei zu groß.", "error")
|
||||||
|
return redirect("/settings")
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
flash(request, "Keine gültige JSON-Datei.", "error")
|
||||||
|
return redirect("/settings")
|
||||||
|
with session_scope(state.session_factory) as s:
|
||||||
|
report = import_config(s, state.mounts, data)
|
||||||
|
if report.settings:
|
||||||
|
save_settings(
|
||||||
|
state.session_factory,
|
||||||
|
apply_imported_settings(load_settings(state.session_factory), report.settings),
|
||||||
|
)
|
||||||
|
state.scheduler.sync()
|
||||||
|
if report.created:
|
||||||
|
flash(request, "Importiert: " + ", ".join(report.created))
|
||||||
|
if report.skipped:
|
||||||
|
flash(request, "Übersprungen: " + ", ".join(report.skipped), "info")
|
||||||
|
for err in report.errors:
|
||||||
|
flash(request, err, "error")
|
||||||
|
if report.created:
|
||||||
|
flash(
|
||||||
|
request,
|
||||||
|
"Zugangsdaten bitte bei den Anbindungen neu eingeben; importierte Regeln "
|
||||||
|
"sind deaktiviert.",
|
||||||
|
"info",
|
||||||
|
)
|
||||||
|
return redirect("/settings")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Anwendung ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
SECURITY_HEADERS = {
|
||||||
|
"Content-Security-Policy": (
|
||||||
|
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
|
||||||
|
"frame-ancestors 'none'; form-action 'self'; base-uri 'none'"
|
||||||
|
),
|
||||||
|
"X-Frame-Options": "DENY",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"Referrer-Policy": "same-origin",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(settings: Settings | None = None, state: NetMover | None = None) -> FastAPI:
|
||||||
|
settings = settings or get_settings()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
app_state = state or NetMover(settings)
|
||||||
|
app.state.nm = app_state
|
||||||
|
app.state.setup_code = auth.SetupCode()
|
||||||
|
app.state.throttle = auth.LoginThrottle()
|
||||||
|
app.state.tasks = set()
|
||||||
|
if not auth.password_is_set(app_state.session_factory):
|
||||||
|
event(
|
||||||
|
log,
|
||||||
|
"setup.required",
|
||||||
|
level=30,
|
||||||
|
code=app.state.setup_code.code,
|
||||||
|
hint="Diesen Code bei der Ersteinrichtung im Browser eingeben",
|
||||||
|
)
|
||||||
|
app_state.start()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
app_state.stop()
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="NetMover",
|
||||||
|
version=__version__,
|
||||||
|
lifespan=lifespan,
|
||||||
|
docs_url=None,
|
||||||
|
redoc_url=None,
|
||||||
|
openapi_url=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def security_headers(
|
||||||
|
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||||
|
) -> Response:
|
||||||
|
response = await call_next(request)
|
||||||
|
for k, v in SECURITY_HEADERS.items():
|
||||||
|
response.headers.setdefault(k, v)
|
||||||
|
if request.url.path not in ("/healthz",) and not request.url.path.startswith("/static/"):
|
||||||
|
response.headers.setdefault("Cache-Control", "no-store")
|
||||||
|
return response
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
SessionMiddleware,
|
||||||
|
secret_key=auth.load_or_create_session_key(settings.session_key_path),
|
||||||
|
session_cookie="netmover_session",
|
||||||
|
max_age=settings.session_hours * 3600,
|
||||||
|
same_site="lax",
|
||||||
|
https_only=settings.cookie_secure,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(auth.LoginRequired)
|
||||||
|
async def login_required(request: Request, exc: auth.LoginRequired) -> Response:
|
||||||
|
target = "/setup" if exc.setup else f"/login?next={request.url.path}"
|
||||||
|
if request.headers.get("hx-request"):
|
||||||
|
return Response(status_code=204, headers={"HX-Redirect": target})
|
||||||
|
return redirect(target)
|
||||||
|
|
||||||
|
app.mount(
|
||||||
|
"/static", StaticFiles(directory=str(files("netmover.web") / "static")), name="static"
|
||||||
|
)
|
||||||
|
app.include_router(public)
|
||||||
|
app.include_router(protected)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["AppSettings", "create_app"]
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""Anmeldung, Einrichtung beim ersten Start, CSRF und Sperre bei Fehlversuchen.
|
||||||
|
|
||||||
|
* Ein einziges Administratorkonto; Passwort-Hash (Argon2) in ``settings``.
|
||||||
|
* Erster Start: Ohne Passwort führt jede Seite zur Einrichtung. Dafür wird ein
|
||||||
|
einmaliger Einrichtungscode ins Log geschrieben – so kann nicht irgendwer im
|
||||||
|
LAN, der die Seite zuerst öffnet, das Passwort festlegen.
|
||||||
|
* Session-Cookie ``HttpOnly``, ``SameSite=Lax`` (Starlette-SessionMiddleware,
|
||||||
|
signiert). CSRF-Token pro Session, Pflicht bei jedem POST – als Formularfeld
|
||||||
|
``csrf_token`` oder Header ``X-CSRF-Token`` (HTMX).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import InvalidHashError, VerificationError
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from netmover.settings_store import get_value, set_value
|
||||||
|
|
||||||
|
PASSWORD_KEY = "auth.admin_password_hash" # noqa: S105 – Schlüsselname
|
||||||
|
MIN_PASSWORD_LENGTH = 10
|
||||||
|
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||||
|
|
||||||
|
_hasher = PasswordHasher()
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequired(Exception):
|
||||||
|
def __init__(self, setup: bool = False) -> None:
|
||||||
|
self.setup = setup
|
||||||
|
|
||||||
|
|
||||||
|
def load_or_create_session_key(path: Path) -> str:
|
||||||
|
import os
|
||||||
|
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
except FileExistsError:
|
||||||
|
return path.read_text().strip()
|
||||||
|
key = secrets.token_urlsafe(48)
|
||||||
|
with os.fdopen(fd, "w") as fh:
|
||||||
|
fh.write(key + "\n")
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def password_is_set(factory: sessionmaker[Session]) -> bool:
|
||||||
|
return bool(get_value(factory, PASSWORD_KEY))
|
||||||
|
|
||||||
|
|
||||||
|
def set_password(factory: sessionmaker[Session], password: str) -> None:
|
||||||
|
set_value(factory, PASSWORD_KEY, _hasher.hash(password))
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(factory: sessionmaker[Session], password: str) -> bool:
|
||||||
|
stored = get_value(factory, PASSWORD_KEY)
|
||||||
|
if not stored:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
_hasher.verify(stored, password)
|
||||||
|
except (VerificationError, InvalidHashError):
|
||||||
|
return False
|
||||||
|
if _hasher.check_needs_rehash(stored):
|
||||||
|
set_value(factory, PASSWORD_KEY, _hasher.hash(password))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def password_problem(password: str, confirm: str) -> str | None:
|
||||||
|
if len(password) < MIN_PASSWORD_LENGTH:
|
||||||
|
return f"Mindestens {MIN_PASSWORD_LENGTH} Zeichen."
|
||||||
|
if password != confirm:
|
||||||
|
return "Die Passwörter stimmen nicht überein."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# -- Session / CSRF -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def csrf_token(request: Request) -> str:
|
||||||
|
token = request.session.get("csrf")
|
||||||
|
if not isinstance(token, str):
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
request.session["csrf"] = token
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def login_session(request: Request) -> None:
|
||||||
|
"""Neue Session nach erfolgreicher Anmeldung (Schutz vor Session-Fixation)."""
|
||||||
|
request.session.clear()
|
||||||
|
request.session["user"] = "admin"
|
||||||
|
request.session["csrf"] = secrets.token_urlsafe(32)
|
||||||
|
request.session["since"] = int(time.time())
|
||||||
|
|
||||||
|
|
||||||
|
def is_logged_in(request: Request) -> bool:
|
||||||
|
return request.session.get("user") == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
async def check_csrf(request: Request) -> None:
|
||||||
|
if request.method in SAFE_METHODS:
|
||||||
|
return
|
||||||
|
expected = request.session.get("csrf")
|
||||||
|
sent = request.headers.get("x-csrf-token")
|
||||||
|
if sent is None:
|
||||||
|
form = await request.form()
|
||||||
|
value = form.get("csrf_token")
|
||||||
|
sent = value if isinstance(value, str) else None
|
||||||
|
if not expected or not sent or not hmac.compare_digest(str(expected), sent):
|
||||||
|
raise HTTPException(status_code=403, detail="CSRF-Token fehlt oder ist ungültig.")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Sperre bei Fehlversuchen --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class LoginThrottle:
|
||||||
|
"""Nach ``limit`` Fehlversuchen innerhalb von ``window`` Sekunden wird die
|
||||||
|
Client-IP für ``lockout`` Sekunden gesperrt."""
|
||||||
|
|
||||||
|
def __init__(self, limit: int = 5, window: float = 900, lockout: float = 300) -> None:
|
||||||
|
self.limit = limit
|
||||||
|
self.window = window
|
||||||
|
self.lockout = lockout
|
||||||
|
self._fails: dict[str, deque[float]] = defaultdict(deque)
|
||||||
|
self._locked: dict[str, float] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def locked_for(self, client: str) -> float:
|
||||||
|
with self._lock:
|
||||||
|
until = self._locked.get(client, 0)
|
||||||
|
return max(0.0, until - time.monotonic())
|
||||||
|
|
||||||
|
def failure(self, client: str) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
q = self._fails[client]
|
||||||
|
q.append(now)
|
||||||
|
while q and now - q[0] > self.window:
|
||||||
|
q.popleft()
|
||||||
|
if len(q) >= self.limit:
|
||||||
|
self._locked[client] = now + self.lockout
|
||||||
|
q.clear()
|
||||||
|
|
||||||
|
def success(self, client: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._fails.pop(client, None)
|
||||||
|
self._locked.pop(client, None)
|
||||||
|
|
||||||
|
|
||||||
|
class SetupCode:
|
||||||
|
"""Einmaliger Code für die Ersteinrichtung (steht im Log)."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.code = "-".join(secrets.token_hex(2) for _ in range(3))
|
||||||
|
|
||||||
|
def matches(self, value: str) -> bool:
|
||||||
|
return hmac.compare_digest(self.code, value.strip().lower())
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/* NetMover – handgeschrieben, ohne Build-Schritt. Hell/Dunkel nach Systemeinstellung. */
|
||||||
|
:root {
|
||||||
|
--bg: #f6f7f9; --card: #fff; --fg: #1d2330; --muted: #5d6676; --line: #dfe3ea;
|
||||||
|
--accent: #2f6fde; --accent-fg: #fff; --ok: #1e8e3e; --warn: #b26a00; --err: #c62828;
|
||||||
|
--ok-bg: #e6f4ea; --warn-bg: #fff4e0; --err-bg: #fdecea; --info-bg: #e8f0fe;
|
||||||
|
--radius: 8px; --gap: 12px;
|
||||||
|
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #12151b; --card: #1b2028; --fg: #e6e9ef; --muted: #9aa3b2; --line: #2c333e;
|
||||||
|
--accent: #6b9bff; --accent-fg: #0b1020; --ok: #57c27a; --warn: #f0a63a; --err: #ff6b6b;
|
||||||
|
--ok-bg: #16301f; --warn-bg: #36280f; --err-bg: #3a1717; --info-bg: #182540;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: var(--bg); color: var(--fg); }
|
||||||
|
a { color: var(--accent); }
|
||||||
|
code, pre, .mono { font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: .9em; }
|
||||||
|
pre { white-space: pre-wrap; word-break: break-word; margin: 0; }
|
||||||
|
|
||||||
|
header.top { background: var(--card); border-bottom: 1px solid var(--line); }
|
||||||
|
header.top .inner { max-width: 1200px; margin: 0 auto; display: flex; align-items: center;
|
||||||
|
gap: var(--gap); padding: 8px 16px; flex-wrap: wrap; }
|
||||||
|
header.top .brand { font-weight: 700; text-decoration: none; color: var(--fg); margin-right: 8px; }
|
||||||
|
nav.main { display: flex; gap: 4px; flex-wrap: wrap; flex: 1; }
|
||||||
|
nav.main a { padding: 6px 10px; border-radius: var(--radius); text-decoration: none; color: var(--fg); }
|
||||||
|
nav.main a.active, nav.main a:hover { background: var(--bg); }
|
||||||
|
main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||||
|
h1 { font-size: 1.4rem; margin: 4px 0 16px; }
|
||||||
|
h2 { font-size: 1.1rem; margin: 24px 0 8px; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.small { font-size: .85em; }
|
||||||
|
|
||||||
|
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px; }
|
||||||
|
.grid { display: grid; gap: var(--gap); grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
|
||||||
|
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.spread { justify-content: space-between; }
|
||||||
|
.stack > * + * { margin-top: 8px; }
|
||||||
|
|
||||||
|
.badge { display: inline-block; padding: 1px 8px; border-radius: 99px; font-size: .8em; font-weight: 600;
|
||||||
|
background: var(--bg); border: 1px solid var(--line); white-space: nowrap; }
|
||||||
|
.badge.ok { background: var(--ok-bg); color: var(--ok); border-color: transparent; }
|
||||||
|
.badge.warn { background: var(--warn-bg); color: var(--warn); border-color: transparent; }
|
||||||
|
.badge.err { background: var(--err-bg); color: var(--err); border-color: transparent; }
|
||||||
|
|
||||||
|
.msg { padding: 8px 12px; border-radius: var(--radius); margin: 0 0 10px; }
|
||||||
|
.msg.ok { background: var(--ok-bg); } .msg.error { background: var(--err-bg); }
|
||||||
|
.msg.info { background: var(--info-bg); } .msg.warn { background: var(--warn-bg); }
|
||||||
|
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius); overflow: hidden; }
|
||||||
|
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||||
|
th { font-size: .8em; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); background: var(--bg); }
|
||||||
|
tr:last-child td { border-bottom: 0; }
|
||||||
|
tr.warn td { background: var(--warn-bg); }
|
||||||
|
tr.err td { background: var(--err-bg); }
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
button, .btn { font: inherit; padding: 6px 12px; border-radius: var(--radius); border: 1px solid var(--line);
|
||||||
|
background: var(--card); color: var(--fg); cursor: pointer; text-decoration: none; display: inline-block; }
|
||||||
|
button.primary, .btn.primary { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||||||
|
button.danger { color: var(--err); }
|
||||||
|
button.small, .btn.small { padding: 3px 9px; font-size: .85em; }
|
||||||
|
button:disabled { opacity: .5; }
|
||||||
|
form.inline { display: inline; margin: 0; }
|
||||||
|
|
||||||
|
form.form { max-width: 760px; }
|
||||||
|
fieldset { border: 1px solid var(--line); border-radius: var(--radius); margin: 0 0 14px; padding: 10px 14px 14px; background: var(--card); }
|
||||||
|
legend { font-weight: 600; padding: 0 6px; }
|
||||||
|
.fields { display: grid; gap: 10px 14px; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.field.wide { grid-column: 1 / -1; }
|
||||||
|
.field label { font-size: .85em; font-weight: 600; }
|
||||||
|
.field .hint { font-size: .8em; color: var(--muted); }
|
||||||
|
.field .error { font-size: .85em; color: var(--err); }
|
||||||
|
input[type=text], input[type=password], input[type=number], input[type=url], select, textarea {
|
||||||
|
font: inherit; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--line);
|
||||||
|
background: var(--bg); color: var(--fg); width: 100%; }
|
||||||
|
textarea { min-height: 70px; }
|
||||||
|
.check { flex-direction: row; align-items: center; gap: 6px; }
|
||||||
|
.has-error input, .has-error select, .has-error textarea { border-color: var(--err); }
|
||||||
|
|
||||||
|
.steps { list-style: none; padding: 0; margin: 0; }
|
||||||
|
.steps li { padding: 3px 0; }
|
||||||
|
.steps .ico { display: inline-block; width: 1.4em; font-weight: 700; }
|
||||||
|
.steps .ok { color: var(--ok); } .steps .fail { color: var(--err); } .steps .skip { color: var(--muted); }
|
||||||
|
|
||||||
|
details > summary { cursor: pointer; }
|
||||||
|
.htmx-indicator { display: none; }
|
||||||
|
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: inline; }
|
||||||
|
.login { max-width: 380px; margin: 10vh auto; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
main { padding: 10px; }
|
||||||
|
th, td { padding: 6px; }
|
||||||
|
table.responsive thead { display: none; }
|
||||||
|
table.responsive tr { display: block; border-bottom: 1px solid var(--line); padding: 6px 0; }
|
||||||
|
table.responsive td { display: flex; justify-content: space-between; gap: 10px; border: 0; padding: 3px 8px; }
|
||||||
|
table.responsive td::before { content: attr(data-label); color: var(--muted); font-size: .85em; }
|
||||||
|
table.responsive td.num { text-align: left; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-err { color: var(--err); } .text-warn { color: var(--warn); } .text-ok { color: var(--ok); }
|
||||||
|
pre.text-err { margin-top: 6px; }
|
||||||
|
ul.tight { margin: 4px 0 0; padding-left: 20px; }
|
||||||
|
.foot { text-align: center; padding: 16px; }
|
||||||
|
meter.fill { width: 100%; height: 8px; margin: 6px 0 2px; display: block; }
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// NetMover – kleine Helfer. Keine Inline-Skripte (CSP: script-src 'self').
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
var csrf = meta ? meta.getAttribute("content") : "";
|
||||||
|
|
||||||
|
// CSRF-Token für alle HTMX-Anfragen
|
||||||
|
document.addEventListener("htmx:configRequest", function (e) {
|
||||||
|
e.detail.headers["X-CSRF-Token"] = csrf;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rückfrage vor gefährlichen Aktionen: <form data-confirm="…">
|
||||||
|
document.addEventListener("submit", function (e) {
|
||||||
|
var msg = e.target.getAttribute && e.target.getAttribute("data-confirm");
|
||||||
|
if (msg && !window.confirm(msg)) { e.preventDefault(); }
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// Felder je nach Auswahl ein-/ausblenden: data-show-for="select-name:wert1 wert2"
|
||||||
|
function refresh(form) {
|
||||||
|
form.querySelectorAll("[data-show-for]").forEach(function (el) {
|
||||||
|
var spec = el.getAttribute("data-show-for").split(":");
|
||||||
|
var ctl = form.querySelector('[name="' + spec[0] + '"]:checked') ||
|
||||||
|
form.querySelector('select[name="' + spec[0] + '"]');
|
||||||
|
var value = ctl ? ctl.value : "";
|
||||||
|
var show = spec[1].split(" ").indexOf(value) !== -1;
|
||||||
|
el.hidden = !show;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.querySelectorAll("form[data-dynamic]").forEach(function (form) {
|
||||||
|
form.addEventListener("change", function () { refresh(form); });
|
||||||
|
refresh(form);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Öffentlichen Schlüssel kopieren
|
||||||
|
document.addEventListener("click", function (e) {
|
||||||
|
var btn = e.target.closest && e.target.closest("[data-copy]");
|
||||||
|
if (!btn) return;
|
||||||
|
var src = document.getElementById(btn.getAttribute("data-copy"));
|
||||||
|
if (src && navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(src.textContent.trim()).then(function () {
|
||||||
|
btn.textContent = "kopiert ✓";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,36 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="csrf-token" content="{{ csrf }}">
|
||||||
|
<meta name="htmx-config" content='{"includeIndicatorStyles": false, "allowEval": false}'>
|
||||||
|
<title>{% block title %}NetMover{% endblock %} · NetMover</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css?v={{ version }}">
|
||||||
|
<script src="/static/htmx.min.js?v=2.0.4" defer></script>
|
||||||
|
<script src="/static/app.js?v={{ version }}" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% if logged_in %}
|
||||||
|
<header class="top"><div class="inner">
|
||||||
|
<a class="brand" href="/">NetMover</a>
|
||||||
|
<nav class="main">
|
||||||
|
<a href="/" class="{{ 'active' if active == 'status' }}">Status</a>
|
||||||
|
<a href="/mounts" class="{{ 'active' if active == 'mounts' }}">Anbindungen</a>
|
||||||
|
<a href="/rules" class="{{ 'active' if active == 'rules' }}">Regeln</a>
|
||||||
|
<a href="/log" class="{{ 'active' if active == 'log' }}">Protokoll</a>
|
||||||
|
<a href="/settings" class="{{ 'active' if active == 'settings' }}">Einstellungen</a>
|
||||||
|
</nav>
|
||||||
|
<form class="inline" method="post" action="/logout">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf }}">
|
||||||
|
<button class="small">Abmelden</button>
|
||||||
|
</form>
|
||||||
|
</div></header>
|
||||||
|
{% endif %}
|
||||||
|
<main>
|
||||||
|
{% for kind, text in flashes %}<p class="msg {{ kind }}">{{ text }}</p>{% endfor %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
<footer class="foot muted small">NetMover {{ version }}</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "macros.html" import run_badge %}
|
||||||
|
{% block title %}Protokoll{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Protokoll</h1>
|
||||||
|
<form class="row card" method="get">
|
||||||
|
<select name="rule"><option value="">alle Regeln</option>
|
||||||
|
{% for id, name in rules %}<option value="{{ id }}" {{ 'selected' if id == rule_id }}>{{ name }}</option>{% endfor %}</select>
|
||||||
|
<select name="status"><option value="">jeder Status</option>
|
||||||
|
{% for st in [RunStatus.OK, RunStatus.PARTIAL, RunStatus.ERROR, RunStatus.SKIPPED] %}
|
||||||
|
<option value="{{ st.value }}" {{ 'selected' if st.value == status }}>{{ RUN_LABELS[st] }}</option>{% endfor %}</select>
|
||||||
|
<label class="row small"><input type="checkbox" name="all" value="1" {{ 'checked' if not active_only }}> auch leere Läufe</label>
|
||||||
|
<button>Filtern</button>
|
||||||
|
<span class="muted small">{{ total }} Läufe</span>
|
||||||
|
</form>
|
||||||
|
<div class="table-wrap"><table class="responsive">
|
||||||
|
<thead><tr><th>Zeitpunkt</th><th>Regel</th><th>Status</th><th class="num">Dauer</th><th class="num">gesehen</th><th class="num">übertragen</th><th class="num">übersprungen</th><th class="num">Fehler</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for run, rule_name in rows %}
|
||||||
|
<tr class="{{ 'err' if run.status == RunStatus.ERROR }}">
|
||||||
|
<td data-label="Zeitpunkt">{{ run.started_at|localdt }}{% if run.dry_run %} <span class="badge">Probe</span>{% endif %}{% if run.trigger.value == 'manual' %} <span class="badge">manuell</span>{% endif %}</td>
|
||||||
|
<td data-label="Regel">{{ rule_name }}</td>
|
||||||
|
<td data-label="Status">{{ run_badge(run.status) }}</td>
|
||||||
|
<td data-label="Dauer" class="num">{{ duration(run.started_at, run.finished_at) }}</td>
|
||||||
|
<td data-label="gesehen" class="num">{{ run.seen }}</td>
|
||||||
|
<td data-label="übertragen" class="num">{{ run.moved }}</td>
|
||||||
|
<td data-label="übersprungen" class="num">{{ run.skipped }}</td>
|
||||||
|
<td data-label="Fehler" class="num">{{ run.failed }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if run.message or run.moved or run.skipped or run.failed %}
|
||||||
|
<tr><td colspan="8">
|
||||||
|
{% if run.message %}<div class="small {{ 'text-err' if run.status == RunStatus.ERROR }}">{{ run.message }}</div>{% endif %}
|
||||||
|
{% if run.moved or run.skipped or run.failed %}
|
||||||
|
<details hx-get="/log/{{ run.id }}/items" hx-trigger="toggle once" hx-target="find .items" hx-swap="innerHTML">
|
||||||
|
<summary class="small">Einzeldateien</summary><div class="items muted small">lädt …</div></details>
|
||||||
|
{% endif %}
|
||||||
|
</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="8" class="muted">Keine Läufe.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody></table></div>
|
||||||
|
{% if pages > 1 %}
|
||||||
|
<div class="row">
|
||||||
|
{% set base = '?rule=' ~ (rule_id or '') ~ '&status=' ~ status ~ ('&all=1' if not active_only else '') %}
|
||||||
|
{% if page > 1 %}<a class="btn small" href="{{ base }}&page={{ page - 1 }}">← neuer</a>{% endif %}
|
||||||
|
<span class="small muted">Seite {{ page }} / {{ pages }}</span>
|
||||||
|
{% if page < pages %}<a class="btn small" href="{{ base }}&page={{ page + 1 }}">älter →</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Anmelden{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="login card">
|
||||||
|
<h1>NetMover</h1>
|
||||||
|
{% if error %}<p class="msg error">{{ error }}</p>{% endif %}
|
||||||
|
<form method="post" class="stack">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf }}">
|
||||||
|
<div class="field">
|
||||||
|
<label for="pw">Passwort</label>
|
||||||
|
<input id="pw" type="password" name="password" autocomplete="current-password" autofocus required>
|
||||||
|
</div>
|
||||||
|
<button class="primary">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{% macro field(name, label, value='', type='text', errors={}, hint='', wide=false, placeholder='', attrs='') -%}
|
||||||
|
<div class="field{{ ' wide' if wide }}{{ ' has-error' if errors.get(name) }}">
|
||||||
|
<label for="f-{{ name }}">{{ label }}</label>
|
||||||
|
<input id="f-{{ name }}" type="{{ type }}" name="{{ name }}" value="{{ '' if value is none else value }}"
|
||||||
|
placeholder="{{ placeholder }}" {{ attrs|safe }}>
|
||||||
|
{% if errors.get(name) %}<span class="error">{{ errors[name] }}</span>{% endif %}
|
||||||
|
{% if hint %}<span class="hint">{{ hint }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro select(name, label, options, current, errors={}, hint='') -%}
|
||||||
|
<div class="field{{ ' has-error' if errors.get(name) }}">
|
||||||
|
<label for="f-{{ name }}">{{ label }}</label>
|
||||||
|
<select id="f-{{ name }}" name="{{ name }}">
|
||||||
|
{% for value, text in options %}
|
||||||
|
<option value="{{ value }}" {{ 'selected' if value|string == current|string }}>{{ text }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if errors.get(name) %}<span class="error">{{ errors[name] }}</span>{% endif %}
|
||||||
|
{% if hint %}<span class="hint">{{ hint }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro check(name, label, checked, hint='') -%}
|
||||||
|
<div class="field check">
|
||||||
|
<input id="f-{{ name }}" type="checkbox" name="{{ name }}" {{ 'checked' if checked }}>
|
||||||
|
<label for="f-{{ name }}">{{ label }}</label>
|
||||||
|
{% if hint %}<span class="hint">{{ hint }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro csrf_field(csrf) %}<input type="hidden" name="csrf_token" value="{{ csrf }}">{% endmacro %}
|
||||||
|
|
||||||
|
{% macro state_badge(m) -%}
|
||||||
|
{% set labels = SFTP_STATE_LABELS if m.type == MountType.SFTP else STATE_LABELS %}
|
||||||
|
{% set cls = {'mounted': 'ok', 'degraded': 'warn', 'error': 'err'}.get(m.state.value, '') %}
|
||||||
|
<span class="badge {{ cls }}">{{ labels[m.state] }}</span>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro run_badge(status) -%}
|
||||||
|
{% set cls = {'ok': 'ok', 'partial': 'warn', 'error': 'err'}.get(status.value, '') %}
|
||||||
|
<span class="badge {{ cls }}">{{ RUN_LABELS[status] }}</span>
|
||||||
|
{%- endmacro %}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "macros.html" import field, select, check %}
|
||||||
|
{% block title %}{{ 'Anbindung bearbeiten' if mount else 'Neue Anbindung' }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ 'Anbindung „' ~ mount.name ~ '“ bearbeiten' if mount else 'Neue Anbindung' }}</h1>
|
||||||
|
{% if errors.get('_') %}<p class="msg error">{{ errors['_'] }}</p>{% endif %}
|
||||||
|
<form class="form" method="post" data-dynamic>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf }}">
|
||||||
|
{% if mount %}<input type="hidden" name="mount_id" value="{{ mount.id }}">{% endif %}
|
||||||
|
<fieldset><legend>Verbindung</legend><div class="fields">
|
||||||
|
{{ field('name', 'Name', d.name, errors=errors, attrs='required maxlength="100"', hint=('Verzeichnis: ' ~ mount.mountpoint if mount and mount.mountpoint else 'Bestimmt das Verzeichnis unter /mnt/netmover')) }}
|
||||||
|
{{ select('type', 'Typ', [('cifs', 'SMB/CIFS'), ('nfs4', 'NFS 4'), ('nfs3', 'NFS 3'), ('sftp', 'SFTP (nur Ziel)')], d.type.value, errors=errors) }}
|
||||||
|
{{ field('server', 'Server', d.server, errors=errors, placeholder='192.168.1.10 oder nas.lan', attrs='required') }}
|
||||||
|
<div class="field{{ ' has-error' if errors.get('share') }}">
|
||||||
|
<label for="f-share">Freigabe / Pfad</label>
|
||||||
|
<input id="f-share" type="text" name="share" value="{{ d.share }}" required>
|
||||||
|
{% if errors.get('share') %}<span class="error">{{ errors['share'] }}</span>{% endif %}
|
||||||
|
<span class="hint" data-show-for="type:cifs">Freigabename, z. B. <code>scans</code></span>
|
||||||
|
<span class="hint" data-show-for="type:nfs3 nfs4">Exportpfad, z. B. <code>/volume1/scans</code></span>
|
||||||
|
<span class="hint" data-show-for="type:sftp">Absoluter Basisordner, z. B. <code>/opt/paperless/consume</code></span>
|
||||||
|
</div>
|
||||||
|
<div data-show-for="type:sftp">{{ field('port', 'SSH-Port', d.port or '', type='number', errors=errors, placeholder='22') }}</div>
|
||||||
|
</div></fieldset>
|
||||||
|
|
||||||
|
<fieldset data-show-for="type:cifs sftp"><legend>Anmeldung</legend><div class="fields">
|
||||||
|
<div data-show-for="type:sftp">{{ select('auth', 'Anmeldeart', [('ssh_key', 'SSH-Schlüssel (NetMover erzeugt ihn)'), ('password', 'Passwort')], d.auth.value, errors=errors) }}</div>
|
||||||
|
{{ field('username', 'Benutzername', d.username, errors=errors, attrs='autocomplete="off"', hint='Bei CIFS leer lassen für Gastzugang') }}
|
||||||
|
<div data-show-for="type:cifs">{{ field('domain', 'Domäne (optional)', d.domain, errors=errors) }}</div>
|
||||||
|
<div class="field{{ ' has-error' if errors.get('password') }}" id="pw-field">
|
||||||
|
<label for="f-password">Passwort</label>
|
||||||
|
<input id="f-password" type="password" name="password" autocomplete="new-password" placeholder="{{ '••••••' if has_secret }}">
|
||||||
|
{% if errors.get('password') %}<span class="error">{{ errors['password'] }}</span>{% endif %}
|
||||||
|
<span class="hint">{{ 'Leer lassen = unverändert. ' if has_secret }}Bei SFTP mit Schlüssel leer lassen.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if public_key %}<div class="field wide"><label>Öffentlicher Schlüssel (in ~/.ssh/authorized_keys des Zielbenutzers eintragen)</label>
|
||||||
|
<pre class="mono card" id="pubkey">{{ public_key }}</pre><button class="small" type="button" data-copy="pubkey">kopieren</button></div>{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset data-show-for="type:cifs"><legend>Eigentümer und Rechte (CIFS)</legend><div class="fields">
|
||||||
|
{{ field('uid', 'uid', d.uid, type='number', errors=errors, hint='Eigentümer aller Dateien im Mount') }}
|
||||||
|
{{ field('gid', 'gid', d.gid, type='number', errors=errors) }}
|
||||||
|
{{ field('file_mode', 'file_mode', d.file_mode, errors=errors, placeholder='0644') }}
|
||||||
|
{{ field('dir_mode', 'dir_mode', d.dir_mode, errors=errors, placeholder='0755') }}
|
||||||
|
</div></fieldset>
|
||||||
|
|
||||||
|
<fieldset data-show-for="type:cifs nfs3 nfs4"><legend>Erweitert</legend><div class="fields">
|
||||||
|
{{ field('extra_options', 'Zusatzoptionen', d.extra_options, errors=errors, wide=true, placeholder='vers=3.0,noserverino', hint='Kommagetrennt; nur erlaubte Optionen. Zugangsdaten setzt NetMover selbst.') }}
|
||||||
|
{{ check('auto_mount', 'Beim Start automatisch einhängen', d.auto_mount) }}
|
||||||
|
</div></fieldset>
|
||||||
|
{{ check('enabled', 'Aktiv', d.enabled) }}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button class="primary">Speichern</button>
|
||||||
|
<button type="button" hx-post="/mounts/test" hx-include="closest form" hx-target="#form-check" hx-swap="innerHTML">
|
||||||
|
Testen <span class="htmx-indicator">(läuft …)</span></button>
|
||||||
|
<a class="btn" href="/mounts">Abbrechen</a>
|
||||||
|
</div>
|
||||||
|
<div id="form-check"></div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "macros.html" import state_badge, csrf_field %}
|
||||||
|
{% block title %}Anbindungen{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row spread"><h1>Anbindungen</h1><a class="btn primary" href="/mounts/new">+ Neue Anbindung</a></div>
|
||||||
|
{% if not mounts %}<p class="muted">Noch keine Anbindungen.</p>{% endif %}
|
||||||
|
<div class="stack">
|
||||||
|
{% for m in mounts %}
|
||||||
|
<div class="card" id="mount-{{ m.id }}">
|
||||||
|
<div class="row spread">
|
||||||
|
<div><strong>{{ m.name }}</strong> {{ state_badge(m) }}
|
||||||
|
{% if not m.enabled %}<span class="badge">deaktiviert</span>{% endif %}
|
||||||
|
{% if m.auto_mount %}<span class="badge">auto</span>{% endif %}</div>
|
||||||
|
<div class="row">
|
||||||
|
<button class="small" hx-post="/mounts/{{ m.id }}/test" hx-target="#check-{{ m.id }}" hx-swap="innerHTML">
|
||||||
|
Testen <span class="htmx-indicator">…</span></button>
|
||||||
|
{% if m.type != MountType.SFTP %}
|
||||||
|
{% if m.state == MountState.MOUNTED or m.state == MountState.DEGRADED %}
|
||||||
|
<form class="inline" method="post" action="/mounts/{{ m.id }}/unmount" data-confirm="„{{ m.name }}“ aushängen?">{{ csrf_field(csrf) }}<button class="small">Aushängen</button></form>
|
||||||
|
{% else %}
|
||||||
|
<form class="inline" method="post" action="/mounts/{{ m.id }}/mount">{{ csrf_field(csrf) }}<button class="small">Einhängen</button></form>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<a class="btn small" href="/mounts/{{ m.id }}/edit">Bearbeiten</a>
|
||||||
|
<form class="inline" method="post" action="/mounts/{{ m.id }}/delete" data-confirm="„{{ m.name }}“ wirklich löschen?">{{ csrf_field(csrf) }}<button class="small danger">Löschen</button></form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="muted small mono">{{ m.type.value }} · {% if m.type == MountType.CIFS %}//{{ m.server }}/{{ m.share }}{% elif m.type == MountType.SFTP %}{{ m.server }}:{{ m.port or 22 }}{{ m.share }}{% else %}{{ m.server }}:{{ m.share }}{% endif %}
|
||||||
|
{% if m.mountpoint %} → {{ m.mountpoint }}{% endif %}</div>
|
||||||
|
{% if m.last_error %}<pre class="small text-err">{{ m.last_error }}</pre>{% endif %}
|
||||||
|
{% if m.type == MountType.SFTP %}
|
||||||
|
<div class="small">Hostschlüssel: {% if m.host_key %}<span class="mono">{{ m.host_key.split()[0] }}</span> festgehalten
|
||||||
|
<form class="inline" method="post" action="/mounts/{{ m.id }}/reset-hostkey" data-confirm="Hostschlüssel zurücksetzen? Nur tun, wenn der Server neu aufgesetzt wurde.">{{ csrf_field(csrf) }}<button class="small">zurücksetzen</button></form>
|
||||||
|
{% else %}<span class="text-warn">noch nicht bestätigt – bitte „Testen“</span>{% endif %}</div>
|
||||||
|
{% if pubkeys.get(m.id) %}
|
||||||
|
<details class="small"><summary>Öffentlicher Schlüssel für authorized_keys</summary>
|
||||||
|
<pre class="mono" id="pub-{{ m.id }}">{{ pubkeys[m.id] }}</pre>
|
||||||
|
<button class="small" type="button" data-copy="pub-{{ m.id }}">kopieren</button></details>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<div id="check-{{ m.id }}">
|
||||||
|
{% if reports.get(m.id) %}<details class="small"><summary class="muted">Letzter Test {{ m.last_test_at|ago }}</summary>
|
||||||
|
{% with report = reports[m.id], errors = {} %}{% include "partials/check_result.html" %}{% endwith %}</details>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{% set step_names = {'mount': 'Einhängen', 'read': 'Lesen', 'write': 'Schreiben', 'connect': 'Verbinden'} %}
|
||||||
|
<div class="card stack">
|
||||||
|
{% if errors %}
|
||||||
|
<p class="msg error">Eingaben unvollständig:</p>
|
||||||
|
<ul class="tight">{% for k, v in errors.items() %}<li><strong>{{ k }}</strong>: {{ v }}</li>{% endfor %}</ul>
|
||||||
|
{% elif report %}
|
||||||
|
<ul class="steps">
|
||||||
|
{% for st in report.steps %}
|
||||||
|
<li><span class="ico {{ 'ok' if st.ok else 'skip' if st.ok is none else 'fail' }}">{{ '✓' if st.ok else '–' if st.ok is none else '✗' }}</span>
|
||||||
|
<strong>{{ step_names.get(st.name, st.name) }}</strong>{% if st.message %}: <span class="{{ 'text-err' if st.ok == false }}">{{ st.message }}</span>{% endif %}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<div class="small">Geeignet als:
|
||||||
|
{% for role in report.roles %}<span class="badge ok">{{ role.value }}</span> {% else %}<span class="badge err">nichts</span>{% endfor %}</div>
|
||||||
|
{% if report.owner %}<div class="small muted">Testdatei gehörte {{ report.owner }}{% if report.root_squash %} (Root-Squash){% endif %}</div>{% endif %}
|
||||||
|
{% if report.free_bytes is not none %}<div class="small muted">{{ report.free_bytes|bytes }} frei von {{ report.total_bytes|bytes }}</div>{% endif %}
|
||||||
|
{% if report.host_key_fingerprint %}<div class="small">Hostschlüssel: <span class="mono">{{ report.host_key_fingerprint }}</span></div>{% endif %}
|
||||||
|
{% for h in report.hints %}<p class="msg info small">{{ h }}</p>{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% from "macros.html" import run_badge %}
|
||||||
|
<div class="card stack">
|
||||||
|
{% if not run %}<p class="msg error">Probelauf nicht möglich (Regel gelöscht?).</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="row"><strong>Probelauf</strong> {{ run_badge(run.status) }}
|
||||||
|
<span class="muted small">{{ run.seen }} gefunden · {{ run.moved }} würden übertragen · {{ run.skipped }} übersprungen</span></div>
|
||||||
|
{% if run.message %}<p class="msg {{ 'error' if run.status == RunStatus.ERROR else 'info' }} small">{{ run.message }}</p>{% endif %}
|
||||||
|
{% if items %}
|
||||||
|
<div class="table-wrap"><table class="responsive small">
|
||||||
|
<thead><tr><th>Datei</th><th>Ziel</th><th>Ergebnis</th></tr></thead>
|
||||||
|
<tbody>{% for i in items %}
|
||||||
|
<tr><td data-label="Datei" class="mono">{{ i.src }}</td><td data-label="Ziel" class="mono">{{ i.dst or '–' }}</td>
|
||||||
|
<td data-label="Ergebnis">{{ i.message or i.status.value }}</td></tr>
|
||||||
|
{% endfor %}</tbody></table></div>
|
||||||
|
{% elif run.status != RunStatus.ERROR %}<p class="muted small">Keine passenden Dateien.</p>{% endif %}
|
||||||
|
<p class="muted small">Es wurde nichts verändert.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{% set cls = {'moved': 'ok', 'copied': 'ok', 'planned': 'ok', 'failed': 'err', 'quarantined': 'warn'} %}
|
||||||
|
{% set labels = {'moved': 'verschoben', 'copied': 'kopiert', 'skipped': 'übersprungen', 'failed': 'Fehler', 'quarantined': 'Quarantäne', 'planned': 'geplant'} %}
|
||||||
|
<table class="responsive small">
|
||||||
|
<thead><tr><th>Datei</th><th>Ziel</th><th class="num">Größe</th><th>Ergebnis</th></tr></thead>
|
||||||
|
<tbody>{% for i in items %}
|
||||||
|
<tr><td data-label="Datei" class="mono">{{ i.src }}</td><td data-label="Ziel" class="mono">{{ i.dst or '–' }}</td>
|
||||||
|
<td data-label="Größe" class="num">{{ i.size|bytes }}</td>
|
||||||
|
<td data-label="Ergebnis"><span class="badge {{ cls.get(i.status.value, '') }}">{{ labels[i.status.value] }}</span>
|
||||||
|
{% if i.message %}<span class="{{ 'text-err' if i.status.value == 'failed' }}">{{ i.message }}</span>{% endif %}</td></tr>
|
||||||
|
{% else %}<tr><td colspan="4" class="muted">Keine Einzeldateien protokolliert.</td></tr>{% endfor %}
|
||||||
|
</tbody></table>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{% from "macros.html" import state_badge, run_badge %}
|
||||||
|
{% if not health.ok %}
|
||||||
|
<div class="msg error"><strong>Achtung:</strong>
|
||||||
|
<ul class="tight">{% for p in health.problems %}<li>{{ p }}</li>{% endfor %}</ul></div>
|
||||||
|
{% else %}
|
||||||
|
<p class="msg ok">Alles in Ordnung.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>Anbindungen</h2>
|
||||||
|
{% if not mounts %}<p class="muted">Noch keine Anbindungen – <a href="/mounts/new">jetzt anlegen</a>.</p>{% endif %}
|
||||||
|
<div class="grid">
|
||||||
|
{% for v in mounts %}{% set m = v.m %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="row spread"><strong><a href="/mounts#mount-{{ m.id }}">{{ m.name }}</a></strong>{{ state_badge(m) }}</div>
|
||||||
|
<div class="muted small mono">{{ m.type.value }} · {{ m.server }}{{ ':' if m.type != MountType.CIFS else '/' }}{{ m.share }}</div>
|
||||||
|
{% if m.state == MountState.MOUNTED and m.last_mounted_at and m.type != MountType.SFTP %}
|
||||||
|
<div class="small">eingehängt seit {{ m.last_mounted_at|localdt }}</div>
|
||||||
|
{% elif m.state in (MountState.DEGRADED, MountState.ERROR) %}
|
||||||
|
<div class="small">{{ 'gestört' if m.state == MountState.DEGRADED else 'Fehler' }} seit {{ m.state_since|localdt }} ({{ m.state_since|ago }})</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if v.usage %}{% set used = (v.usage[0] - v.usage[1]) %}{% set pct = (100 * used / v.usage[0]) if v.usage[0] else 0 %}
|
||||||
|
<meter class="fill" min="0" max="100" low="85" high="95" optimum="0" value="{{ pct|round(1) }}">{{ pct|round(0)|int }} %</meter>
|
||||||
|
<div class="small muted">{{ v.usage[1]|bytes }} frei von {{ v.usage[0]|bytes }} ({{ pct|round(0)|int }} % belegt)</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if m.last_error %}<pre class="small text-err">{{ m.last_error }}</pre>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Regeln</h2>
|
||||||
|
{% if not rules %}<p class="muted">Noch keine Regeln – <a href="/rules/new">jetzt anlegen</a>.</p>{% else %}
|
||||||
|
<div class="table-wrap"><table class="responsive">
|
||||||
|
<thead><tr><th>Regel</th><th>Letztes Ergebnis</th><th>Letzter Erfolg</th><th>Nächster Lauf</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for v in rules %}{% set r = v.r %}
|
||||||
|
<tr class="{{ 'warn' if v.stale }}">
|
||||||
|
<td data-label="Regel"><strong>{{ r.name }}</strong>{% if not r.enabled %} <span class="badge">aus</span>{% endif %}{% if r.dry_run %} <span class="badge warn">Probelauf</span>{% endif %}
|
||||||
|
<div class="muted small mono">{{ v.target }}</div></td>
|
||||||
|
<td data-label="Letztes Ergebnis">
|
||||||
|
{% if v.running_since %}<span class="badge warn">läuft seit {{ v.running_since|ago|replace('vor ', '') }}</span>
|
||||||
|
{% elif v.last_run %}{{ run_badge(v.last_run.status) }}
|
||||||
|
<span class="small muted">{{ v.last_run.finished_at|ago }}{% if v.last_run.seen %} · {{ v.last_run.moved }}/{{ v.last_run.seen }}{% endif %}</span>
|
||||||
|
{% if v.last_run.status in (RunStatus.ERROR, RunStatus.PARTIAL) and v.last_run.message %}<div class="small text-err">{{ v.last_run.message }}</div>{% endif %}
|
||||||
|
{% else %}<span class="muted">–</span>{% endif %}</td>
|
||||||
|
<td data-label="Letzter Erfolg">{{ r.last_success_at|ago }}{% if v.stale %}<div class="small text-warn">länger als 3 × Intervall!</div>{% endif %}
|
||||||
|
{% if r.consecutive_failures %}<div class="small text-err">{{ r.consecutive_failures }} Fehler in Folge</div>{% endif %}</td>
|
||||||
|
<td data-label="Nächster Lauf">{{ v.next_run|ago if v.next_run else '–' }}</td>
|
||||||
|
<td><form class="inline" method="post" action="/rules/{{ r.id }}/run">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf }}">
|
||||||
|
<button class="small" {{ 'disabled' if v.running_since }}>Jetzt ausführen</button></form></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody></table></div>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "macros.html" import field, select, check %}
|
||||||
|
{% block title %}{{ 'Regel bearbeiten' if rule_id else 'Neue Regel' }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ 'Regel bearbeiten' if rule_id else 'Neue Regel' }}</h1>
|
||||||
|
{% if errors.get('_') %}<p class="msg error">{{ errors['_'] }}</p>{% endif %}
|
||||||
|
{% if not src_mounts %}<p class="msg info">Zuerst eine SMB- oder NFS-Anbindung als Quelle <a href="/mounts/new">anlegen</a>.</p>{% endif %}
|
||||||
|
<form class="form" method="post" data-dynamic>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf }}">
|
||||||
|
<fieldset><legend>Allgemein</legend><div class="fields">
|
||||||
|
{{ field('name', 'Name', d.name, errors=errors, attrs='required maxlength="100"') }}
|
||||||
|
{{ field('interval_seconds', 'Intervall (Sekunden)', d.interval_seconds, type='number', errors=errors, attrs='min="10" required', hint='mindestens 10') }}
|
||||||
|
{{ check('enabled', 'Aktiv', d.enabled) }}
|
||||||
|
{{ check('dry_run', 'Nur Probelauf (nichts verändern)', d.dry_run) }}
|
||||||
|
</div></fieldset>
|
||||||
|
|
||||||
|
<fieldset><legend>Quelle</legend><div class="fields">
|
||||||
|
{{ select('src_mount_id', 'Anbindung', src_options, d.src_mount_id or '', errors=errors) }}
|
||||||
|
{{ field('src_subpath', 'Unterordner', d.src_subpath, errors=errors, placeholder='z. B. Scans/Max', hint='relativ zur Freigabe; leer = Wurzel') }}
|
||||||
|
{{ select('pattern_type', 'Filterart', [('glob', 'Glob'), ('regex', 'Regulärer Ausdruck')], d.pattern_type.value, errors=errors) }}
|
||||||
|
{{ field('pattern', 'Filter', d.pattern, errors=errors, hint='Glob: *.pdf;*.jpg (Dateiname, ohne Groß/klein). Regex: gegen relativen Pfad.') }}
|
||||||
|
{{ check('recursive', 'Unterordner einbeziehen (Struktur bleibt erhalten)', d.recursive) }}
|
||||||
|
</div></fieldset>
|
||||||
|
|
||||||
|
<fieldset><legend>Ziel</legend><div class="fields">
|
||||||
|
{{ select('dst_kind', 'Zielart', [('mount', 'Anbindung'), ('local', 'Lokaler Pfad')], d.dst_kind, errors=errors) }}
|
||||||
|
<div data-show-for="dst_kind:mount">{{ select('dst_mount_id', 'Anbindung', dst_options, d.dst_mount_id or '', errors=errors) }}</div>
|
||||||
|
{{ field('dst_path', 'Zielordner', d.dst_path, errors=errors, hint='Anbindung: relativer Unterordner (wird angelegt). Lokal: absoluter Pfad, muss existieren.') }}
|
||||||
|
{{ select('action', 'Aktion', [('move', 'verschieben'), ('copy', 'kopieren (Quelle bleibt)')], d.action.value, errors=errors) }}
|
||||||
|
{{ select('on_collision', 'Wenn Datei existiert', [('rename', 'umbenennen (scan-1.pdf)'), ('skip', 'überspringen'), ('overwrite', 'überschreiben')], d.on_collision.value, errors=errors) }}
|
||||||
|
</div></fieldset>
|
||||||
|
|
||||||
|
<fieldset><legend>Sicherheit und Grenzen</legend><div class="fields">
|
||||||
|
{{ field('stability_seconds', 'Stabilitätsfenster (s)', d.stability_seconds, type='number', errors=errors, attrs='min="0"', hint='Größe und Zeit müssen so lange gleich bleiben') }}
|
||||||
|
{{ field('min_age_seconds', 'Mindestalter (s)', d.min_age_seconds, type='number', errors=errors, attrs='min="0"') }}
|
||||||
|
{{ field('max_size_mb', 'Max. Größe (MB)', d.max_size_mb or '', type='number', errors=errors, hint='leer = unbegrenzt') }}
|
||||||
|
{{ field('op_timeout_seconds', 'Zeitlimit ohne Fortschritt (s)', d.op_timeout_seconds, type='number', errors=errors, attrs='min="10"') }}
|
||||||
|
</div></fieldset>
|
||||||
|
|
||||||
|
<fieldset><legend>Rechte und Quarantäne</legend><div class="fields">
|
||||||
|
{{ field('set_owner', 'Eigentümer setzen', d.set_owner, errors=errors, placeholder='1000:1000', hint='leer = unverändert; auf CIFS wirkungslos') }}
|
||||||
|
{{ field('set_mode', 'Modus setzen', d.set_mode, errors=errors, placeholder='0644', hint='leer = wie Quelle') }}
|
||||||
|
{{ field('quarantine_path', 'Quarantäneordner', d.quarantine_path, errors=errors, placeholder='_quarantaene', hint='relativ zur Quell-Anbindung; leer = keine Quarantäne') }}
|
||||||
|
{{ field('quarantine_after', 'nach Fehlversuchen', d.quarantine_after, type='number', errors=errors, attrs='min="1"') }}
|
||||||
|
</div></fieldset>
|
||||||
|
|
||||||
|
<div class="row"><button class="primary">Speichern</button><a class="btn" href="/rules">Abbrechen</a></div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "macros.html" import run_badge, csrf_field %}
|
||||||
|
{% block title %}Regeln{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row spread"><h1>Regeln</h1><a class="btn primary" href="/rules/new">+ Neue Regel</a></div>
|
||||||
|
{% if not rules %}<p class="muted">Noch keine Regeln.</p>{% endif %}
|
||||||
|
<div class="stack">
|
||||||
|
{% for v in rules %}{% set r = v.r %}
|
||||||
|
<div class="card" id="rule-{{ r.id }}">
|
||||||
|
<div class="row spread">
|
||||||
|
<div><strong>{{ r.name }}</strong>
|
||||||
|
{% if r.enabled %}<span class="badge ok">aktiv</span>{% else %}<span class="badge">aus</span>{% endif %}
|
||||||
|
{% if r.dry_run %}<span class="badge warn">nur Probelauf</span>{% endif %}
|
||||||
|
<span class="badge">{{ 'kopieren' if r.action == RuleAction.COPY else 'verschieben' }}</span>
|
||||||
|
<span class="badge">alle {{ r.interval_seconds }} s</span></div>
|
||||||
|
<div class="row">
|
||||||
|
<form class="inline" method="post" action="/rules/{{ r.id }}/run">{{ csrf_field(csrf) }}<button class="small" {{ 'disabled' if v.running_since }}>Jetzt ausführen</button></form>
|
||||||
|
<button class="small" hx-post="/rules/{{ r.id }}/dryrun" hx-target="#dry-{{ r.id }}" hx-swap="innerHTML">Probelauf <span class="htmx-indicator">…</span></button>
|
||||||
|
<form class="inline" method="post" action="/rules/{{ r.id }}/toggle">{{ csrf_field(csrf) }}<button class="small">{{ 'Deaktivieren' if r.enabled else 'Aktivieren' }}</button></form>
|
||||||
|
<a class="btn small" href="/rules/{{ r.id }}/edit">Bearbeiten</a>
|
||||||
|
<form class="inline" method="post" action="/rules/{{ r.id }}/delete" data-confirm="Regel „{{ r.name }}“ und ihr Protokoll löschen?">{{ csrf_field(csrf) }}<button class="small danger">Löschen</button></form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="muted small mono">{{ v.target }} · Filter {{ r.pattern }}{{ ' (rekursiv)' if r.recursive }}</div>
|
||||||
|
<div class="small">Letzter Lauf: {% if v.running_since %}<span class="badge warn">läuft seit {{ v.running_since|ago|replace('vor ', '') }}</span>{% elif v.last_run %}{{ run_badge(v.last_run.status) }} {{ v.last_run.finished_at|ago }}{% else %}–{% endif %}
|
||||||
|
· letzter Erfolg: <span class="{{ 'text-warn' if v.stale }}">{{ r.last_success_at|ago }}</span>
|
||||||
|
· nächster: {{ v.next_run|ago if v.next_run else '–' }}</div>
|
||||||
|
<div id="dry-{{ r.id }}"></div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "macros.html" import field, select, check, csrf_field %}
|
||||||
|
{% block title %}Einstellungen{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Einstellungen</h1>
|
||||||
|
<form class="form" method="post" action="/settings" data-dynamic>
|
||||||
|
{{ csrf_field(csrf) }}
|
||||||
|
<fieldset><legend>Protokoll</legend><div class="fields">
|
||||||
|
{{ field('retention_days', 'Aufbewahrung (Tage)', cfg.retention_days, type='number', errors=errors, attrs='min="1" max="3650"') }}
|
||||||
|
</div></fieldset>
|
||||||
|
<fieldset><legend>Benachrichtigung (Webhook)</legend><div class="fields">
|
||||||
|
{{ select('notify_mode', 'Benachrichtigen', [('off', 'nie'), ('every', 'bei jedem fehlgeschlagenen Lauf'), ('after_n', 'nach N Fehlschlägen in Folge')], cfg.notify_mode.value, errors=errors) }}
|
||||||
|
<div data-show-for="notify_mode:after_n">{{ field('notify_after', 'N', cfg.notify_after, type='number', errors=errors, attrs='min="1"') }}</div>
|
||||||
|
{{ field('webhook_url', 'Webhook-URL', mask_url(cfg.webhook_url), errors=errors, wide=true, placeholder='https://ntfy.sh/mein-topic', hint='Wird nur gekürzt angezeigt; unverändert lassen = behalten, leeren = entfernen.') }}
|
||||||
|
{{ field('webhook_content_type', 'Content-Type', cfg.webhook_content_type, errors=errors) }}
|
||||||
|
{{ check('notify_recovery', 'Entwarnung senden, wenn es wieder läuft', cfg.notify_recovery) }}
|
||||||
|
<div class="field wide"><label for="f-tpl">Vorlage</label>
|
||||||
|
<textarea id="f-tpl" name="webhook_template" class="mono">{{ cfg.webhook_template }}</textarea>
|
||||||
|
<span class="hint">Platzhalter: $event $title $message $rule $time $host – bei JSON automatisch maskiert.</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="row"><button type="button" hx-post="/settings/webhook-test" hx-target="#hook-result">Test senden (gespeicherte Einstellungen)</button><span id="hook-result"></span></div>
|
||||||
|
</fieldset>
|
||||||
|
<button class="primary">Speichern</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2>Passwort ändern</h2>
|
||||||
|
<form class="form card stack" method="post" action="/settings/password">
|
||||||
|
{{ csrf_field(csrf) }}
|
||||||
|
<div class="fields">
|
||||||
|
<div class="field"><label for="cur">Aktuelles Passwort</label><input id="cur" type="password" name="current" autocomplete="current-password" required></div>
|
||||||
|
<div class="field"><label for="np">Neues Passwort</label><input id="np" type="password" name="password" autocomplete="new-password" minlength="10" required></div>
|
||||||
|
<div class="field"><label for="np2">Wiederholen</label><input id="np2" type="password" name="confirm" autocomplete="new-password" minlength="10" required></div>
|
||||||
|
</div>
|
||||||
|
<button>Passwort ändern</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2>Export / Import</h2>
|
||||||
|
<div class="card stack">
|
||||||
|
<p class="small muted">Export enthält Anbindungen, Regeln und Einstellungen – <strong>ohne</strong> Zugangsdaten und Webhook-URL.
|
||||||
|
Beim Import werden vorhandene Namen übersprungen; importierte Regeln sind zunächst deaktiviert.</p>
|
||||||
|
<div class="row"><a class="btn" href="/settings/export">Konfiguration exportieren</a></div>
|
||||||
|
<form class="row" method="post" action="/settings/import" enctype="multipart/form-data">
|
||||||
|
{{ csrf_field(csrf) }}<input type="file" name="file" accept="application/json,.json" required><button>Importieren</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Einrichtung{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="login card">
|
||||||
|
<h1>Ersteinrichtung</h1>
|
||||||
|
<p class="muted">Bitte ein Administratorpasswort festlegen (mindestens 10 Zeichen).
|
||||||
|
Den Einrichtungscode schreibt NetMover beim Start ins Log:
|
||||||
|
<code>journalctl -u netmover | grep setup</code></p>
|
||||||
|
{% if error %}<p class="msg error">{{ error }}</p>{% endif %}
|
||||||
|
<form method="post" class="stack">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf }}">
|
||||||
|
<div class="field"><label for="code">Einrichtungscode</label>
|
||||||
|
<input id="code" type="text" name="code" autocomplete="off" placeholder="xxxx-xxxx-xxxx" required></div>
|
||||||
|
<div class="field"><label for="pw">Passwort</label>
|
||||||
|
<input id="pw" type="password" name="password" autocomplete="new-password" minlength="10" required></div>
|
||||||
|
<div class="field"><label for="pw2">Passwort wiederholen</label>
|
||||||
|
<input id="pw2" type="password" name="confirm" autocomplete="new-password" minlength="10" required></div>
|
||||||
|
<button class="primary">Passwort setzen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Status{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row spread"><h1>Status</h1>
|
||||||
|
<span class="muted small">aktualisiert sich alle 15 s</span></div>
|
||||||
|
<div id="status-body" hx-get="/partials/status" hx-trigger="every 15s" hx-swap="innerHTML">
|
||||||
|
{% include "partials/status_body.html" %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
"""Weboberfläche: Einrichtung, Anmeldung, CSRF, Seiten und Aktionen (FakeMountBackend)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from netmover.app_state import NetMover
|
||||||
|
from netmover.config import Settings
|
||||||
|
from netmover.db import session_scope
|
||||||
|
from netmover.models import Credential, Mount, MountState, Rule, Run, RunStatus
|
||||||
|
from netmover.mounting.fake import FakeMountBackend
|
||||||
|
from netmover.web.app import create_app
|
||||||
|
|
||||||
|
PASSWORD = "sehr-geheim-123"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def settings(tmp_path: Path) -> Settings:
|
||||||
|
return Settings(
|
||||||
|
data_dir=tmp_path / "lib",
|
||||||
|
run_dir=tmp_path / "run",
|
||||||
|
mount_base=tmp_path / "mnt",
|
||||||
|
mount_backend="fake",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def backend(tmp_path: Path) -> Iterator[FakeMountBackend]:
|
||||||
|
b = FakeMountBackend(tmp_path / "fake")
|
||||||
|
yield b
|
||||||
|
b.release_hangs()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def state(settings: Settings, backend: FakeMountBackend) -> NetMover:
|
||||||
|
return NetMover(settings, backend=backend)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def raw_client(settings: Settings, state: NetMover) -> Iterator[TestClient]:
|
||||||
|
with TestClient(create_app(settings, state), follow_redirects=False) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def csrf_of(html: str) -> str:
|
||||||
|
m = re.search(r'name="csrf-token" content="([^"]+)"', html)
|
||||||
|
assert m, "kein CSRF-Token in der Seite"
|
||||||
|
return m.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def do_setup(c: TestClient) -> str:
|
||||||
|
page = c.get("/setup")
|
||||||
|
token = csrf_of(page.text)
|
||||||
|
code = c.app.state.setup_code.code # type: ignore[attr-defined]
|
||||||
|
r = c.post(
|
||||||
|
"/setup",
|
||||||
|
data={"csrf_token": token, "code": code, "password": PASSWORD, "confirm": PASSWORD},
|
||||||
|
)
|
||||||
|
assert r.status_code == 303, r.text
|
||||||
|
return csrf_of(c.get("/").text)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(raw_client: TestClient) -> TestClient:
|
||||||
|
raw_client.headers["X-Test-CSRF"] = do_setup(raw_client)
|
||||||
|
return raw_client
|
||||||
|
|
||||||
|
|
||||||
|
def post(c: TestClient, url: str, data: dict[str, str] | None = None, **kw: object): # type: ignore[no-untyped-def]
|
||||||
|
payload = {"csrf_token": c.headers["X-Test-CSRF"], **(data or {})}
|
||||||
|
return c.post(url, data=payload, **kw) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
# -- Einrichtung und Anmeldung --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_start_forces_setup(raw_client: TestClient) -> None:
|
||||||
|
for url in ("/", "/mounts", "/rules", "/log", "/settings", "/login"):
|
||||||
|
r = raw_client.get(url)
|
||||||
|
assert r.status_code == 303 and r.headers["location"] == "/setup", url
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_requires_code_and_strong_password(raw_client: TestClient) -> None:
|
||||||
|
token = csrf_of(raw_client.get("/setup").text)
|
||||||
|
code = raw_client.app.state.setup_code.code # type: ignore[attr-defined]
|
||||||
|
bad = [
|
||||||
|
{"code": "falsch", "password": PASSWORD, "confirm": PASSWORD},
|
||||||
|
{"code": code, "password": "kurz", "confirm": "kurz"},
|
||||||
|
{"code": code, "password": PASSWORD, "confirm": PASSWORD + "x"},
|
||||||
|
]
|
||||||
|
for data in bad:
|
||||||
|
r = raw_client.post("/setup", data={"csrf_token": token, **data})
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert raw_client.get("/").status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_only_once(client: TestClient) -> None:
|
||||||
|
assert client.get("/setup").headers["location"] == "/login"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_logout_and_cookie_flags(raw_client: TestClient) -> None:
|
||||||
|
do_setup(raw_client)
|
||||||
|
token = csrf_of(raw_client.get("/").text)
|
||||||
|
raw_client.post("/logout", data={"csrf_token": token})
|
||||||
|
assert raw_client.get("/").status_code == 303
|
||||||
|
page = raw_client.get("/login")
|
||||||
|
r = raw_client.post("/login", data={"csrf_token": csrf_of(page.text), "password": "falsch"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
r = raw_client.post("/login", data={"csrf_token": csrf_of(page.text), "password": PASSWORD})
|
||||||
|
assert r.status_code == 303
|
||||||
|
cookie = r.headers["set-cookie"].lower()
|
||||||
|
assert "httponly" in cookie and "samesite=lax" in cookie
|
||||||
|
assert raw_client.get("/").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_lockout(raw_client: TestClient) -> None:
|
||||||
|
do_setup(raw_client)
|
||||||
|
raw_client.cookies.clear()
|
||||||
|
token = csrf_of(raw_client.get("/login").text)
|
||||||
|
codes = [
|
||||||
|
raw_client.post("/login", data={"csrf_token": token, "password": "x"}).status_code
|
||||||
|
for _ in range(6)
|
||||||
|
]
|
||||||
|
assert codes[:5] == [401] * 5 and codes[5] == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_csrf_required_for_post(client: TestClient) -> None:
|
||||||
|
assert client.post("/rules/1/toggle").status_code == 403
|
||||||
|
assert client.post("/rules/1/toggle", data={"csrf_token": "falsch"}).status_code == 403
|
||||||
|
assert post(client, "/rules/1/toggle").status_code == 303
|
||||||
|
r = client.post("/rules/1/toggle", headers={"X-CSRF-Token": client.headers["X-Test-CSRF"]})
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
def test_htmx_request_without_login_gets_hx_redirect(raw_client: TestClient) -> None:
|
||||||
|
do_setup(raw_client)
|
||||||
|
raw_client.cookies.clear()
|
||||||
|
r = raw_client.get("/partials/status", headers={"HX-Request": "true"})
|
||||||
|
assert r.status_code == 204 and r.headers["HX-Redirect"].startswith("/login")
|
||||||
|
|
||||||
|
|
||||||
|
def test_security_headers(client: TestClient) -> None:
|
||||||
|
r = client.get("/")
|
||||||
|
assert "script-src 'self'" in r.headers["content-security-policy"]
|
||||||
|
assert r.headers["x-frame-options"] == "DENY"
|
||||||
|
assert r.headers["cache-control"] == "no-store"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_inline_scripts_or_styles(client: TestClient) -> None:
|
||||||
|
for url in ("/", "/mounts", "/mounts/new", "/rules", "/rules/new", "/log", "/settings"):
|
||||||
|
html = client.get(url).text
|
||||||
|
assert "style=" not in html, url
|
||||||
|
assert not re.search(r"<script(?![^>]*\bsrc=)", html), url
|
||||||
|
|
||||||
|
|
||||||
|
def test_healthz(client: TestClient, state: NetMover) -> None:
|
||||||
|
r = client.get("/healthz")
|
||||||
|
assert r.status_code == 200 and r.json()["status"] == "ok"
|
||||||
|
with session_scope(state.session_factory) as s:
|
||||||
|
s.add(
|
||||||
|
Mount(
|
||||||
|
name="kaputt",
|
||||||
|
slug="kaputt",
|
||||||
|
type="cifs",
|
||||||
|
server="nas",
|
||||||
|
share="x",
|
||||||
|
mountpoint="/mnt/netmover/kaputt",
|
||||||
|
state=MountState.ERROR,
|
||||||
|
last_error="weg",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
r = client.get("/healthz")
|
||||||
|
assert r.status_code == 503 and "kaputt" in r.json()["problems"][0]
|
||||||
|
|
||||||
|
|
||||||
|
# -- Anbindungen ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
MOUNT_FORM = {
|
||||||
|
"name": "Synology Scans",
|
||||||
|
"type": "cifs",
|
||||||
|
"server": "nas",
|
||||||
|
"share": "scans",
|
||||||
|
"username": "scanner",
|
||||||
|
"password": "Pa$$w0rd,mit=Zeichen",
|
||||||
|
"uid": "1000",
|
||||||
|
"gid": "1000",
|
||||||
|
"file_mode": "0644",
|
||||||
|
"dir_mode": "0755",
|
||||||
|
"extra_options": "vers=3.0",
|
||||||
|
"auto_mount": "on",
|
||||||
|
"enabled": "on",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _mount_id(state: NetMover, name: str) -> int:
|
||||||
|
with state.session_factory() as s:
|
||||||
|
mid = s.scalar(select(Mount.id).where(Mount.name == name))
|
||||||
|
assert mid is not None
|
||||||
|
return mid
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_mount_test_mount_unmount_delete(
|
||||||
|
client: TestClient, state: NetMover, backend: FakeMountBackend
|
||||||
|
) -> None:
|
||||||
|
backend.export("nas", "scans")
|
||||||
|
r = post(client, "/mounts/new", MOUNT_FORM)
|
||||||
|
assert r.status_code == 303, r.text
|
||||||
|
mid = _mount_id(state, "Synology Scans")
|
||||||
|
with state.session_factory() as s:
|
||||||
|
m = s.get(Mount, mid)
|
||||||
|
assert m is not None and m.slug == "synology-scans" and m.credential is not None
|
||||||
|
assert "Pa$$" not in m.credential.secret_enc
|
||||||
|
assert state.secrets.decrypt(m.credential.secret_enc) == MOUNT_FORM["password"]
|
||||||
|
|
||||||
|
# Passwort taucht nirgends auf
|
||||||
|
for url in ("/mounts", f"/mounts/{mid}/edit"):
|
||||||
|
assert MOUNT_FORM["password"] not in client.get(url).text
|
||||||
|
|
||||||
|
r = post(client, f"/mounts/{mid}/test")
|
||||||
|
assert r.status_code == 200 and "Quelle mit Löschen" in r.text and "✓" in r.text
|
||||||
|
|
||||||
|
assert post(client, f"/mounts/{mid}/mount").status_code == 303
|
||||||
|
assert backend.is_mounted(state.mounts.mountpoint_for("synology-scans"))
|
||||||
|
spec = [c for c in backend.calls if c.action == "mount"][-1].spec
|
||||||
|
assert spec is not None and spec.credentials is not None
|
||||||
|
assert spec.credentials.password == MOUNT_FORM["password"]
|
||||||
|
assert "Fehler" not in client.get("/").text
|
||||||
|
|
||||||
|
assert post(client, f"/mounts/{mid}/unmount").status_code == 303
|
||||||
|
assert not backend.mounted
|
||||||
|
assert post(client, f"/mounts/{mid}/delete").status_code == 303
|
||||||
|
with state.session_factory() as s:
|
||||||
|
assert s.scalars(select(Mount)).all() == []
|
||||||
|
assert s.scalars(select(Credential)).all() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_password_keeps_secret(client: TestClient, state: NetMover) -> None:
|
||||||
|
post(client, "/mounts/new", MOUNT_FORM)
|
||||||
|
mid = _mount_id(state, "Synology Scans")
|
||||||
|
r = post(client, f"/mounts/{mid}/edit", {**MOUNT_FORM, "password": "", "username": "neu"})
|
||||||
|
assert r.status_code == 303
|
||||||
|
with state.session_factory() as s:
|
||||||
|
m = s.get(Mount, mid)
|
||||||
|
assert m is not None and m.credential is not None
|
||||||
|
assert m.credential.username == "neu"
|
||||||
|
assert state.secrets.decrypt(m.credential.secret_enc) == MOUNT_FORM["password"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mount_form_validation_shows_errors(client: TestClient, state: NetMover) -> None:
|
||||||
|
bad = {**MOUNT_FORM, "server": "nas;reboot", "extra_options": "password=x"}
|
||||||
|
r = post(client, "/mounts/new", bad)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "Ungültiger Servername" in r.text and "gesperrt" in r.text
|
||||||
|
with state.session_factory() as s:
|
||||||
|
assert s.scalars(select(Mount)).all() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection_change_requires_unmount(
|
||||||
|
client: TestClient, state: NetMover, backend: FakeMountBackend
|
||||||
|
) -> None:
|
||||||
|
backend.export("nas", "scans")
|
||||||
|
post(client, "/mounts/new", MOUNT_FORM)
|
||||||
|
mid = _mount_id(state, "Synology Scans")
|
||||||
|
post(client, f"/mounts/{mid}/mount")
|
||||||
|
r = post(client, f"/mounts/{mid}/edit", {**MOUNT_FORM, "share": "anders", "password": ""})
|
||||||
|
assert r.status_code == 400 and "ausgehängt" in r.text
|
||||||
|
# Nicht-Verbindungsfelder gehen trotzdem
|
||||||
|
r = post(client, f"/mounts/{mid}/edit", {**MOUNT_FORM, "password": "", "auto_mount": ""})
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_test_with_unsaved_data(client: TestClient, backend: FakeMountBackend) -> None:
|
||||||
|
backend.export("nas", "scans")
|
||||||
|
r = post(client, "/mounts/test", MOUNT_FORM)
|
||||||
|
assert r.status_code == 200 and "Einhängen" in r.text and "✓" in r.text
|
||||||
|
r = post(client, "/mounts/test", {**MOUNT_FORM, "share": "fehlt"})
|
||||||
|
assert ("✗" in r.text and "existiert auf dem Server nicht" not in r.text) or "✗" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_sftp_mount_generates_key(client: TestClient, state: NetMover) -> None:
|
||||||
|
r = post(
|
||||||
|
client,
|
||||||
|
"/mounts/new",
|
||||||
|
{
|
||||||
|
"name": "paperless",
|
||||||
|
"type": "sftp",
|
||||||
|
"server": "10.0.0.5",
|
||||||
|
"share": "/opt/consume",
|
||||||
|
"port": "22",
|
||||||
|
"auth": "ssh_key",
|
||||||
|
"username": "paperless",
|
||||||
|
"enabled": "on",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 303, r.text
|
||||||
|
page = client.get("/mounts").text
|
||||||
|
assert "ssh-ed25519 " in page and "BEGIN OPENSSH PRIVATE KEY" not in page
|
||||||
|
with state.session_factory() as s:
|
||||||
|
m = s.scalars(select(Mount)).one()
|
||||||
|
assert m.mountpoint is None and m.auto_mount is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_mount_used_by_rule_is_refused(client: TestClient, state: NetMover) -> None:
|
||||||
|
post(client, "/mounts/new", MOUNT_FORM)
|
||||||
|
mid = _mount_id(state, "Synology Scans")
|
||||||
|
with session_scope(state.session_factory) as s:
|
||||||
|
s.add(Rule(name="r", src_mount_id=mid, dst_path="/tmp"))
|
||||||
|
post(client, f"/mounts/{mid}/delete")
|
||||||
|
assert "Wird noch von Regeln benutzt" in client.get("/mounts").text
|
||||||
|
assert _mount_id(state, "Synology Scans") == mid
|
||||||
|
|
||||||
|
|
||||||
|
# -- Regeln -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mounted(
|
||||||
|
client: TestClient, state: NetMover, backend: FakeMountBackend, tmp_path: Path
|
||||||
|
) -> tuple[int, Path, Path]:
|
||||||
|
remote = backend.export("nas", "scans")
|
||||||
|
post(client, "/mounts/new", MOUNT_FORM)
|
||||||
|
mid = _mount_id(state, "Synology Scans")
|
||||||
|
post(client, f"/mounts/{mid}/mount")
|
||||||
|
consume = tmp_path / "consume"
|
||||||
|
consume.mkdir()
|
||||||
|
return mid, remote, consume
|
||||||
|
|
||||||
|
|
||||||
|
def rule_form(mid: int, consume: Path, **kw: str) -> dict[str, str]:
|
||||||
|
data = {
|
||||||
|
"name": "Scans → Paperless",
|
||||||
|
"enabled": "on",
|
||||||
|
"src_mount_id": str(mid),
|
||||||
|
"src_subpath": "",
|
||||||
|
"dst_kind": "local",
|
||||||
|
"dst_path": str(consume),
|
||||||
|
"pattern": "*.pdf",
|
||||||
|
"pattern_type": "glob",
|
||||||
|
"interval_seconds": "30",
|
||||||
|
"stability_seconds": "0",
|
||||||
|
"min_age_seconds": "0",
|
||||||
|
"op_timeout_seconds": "120",
|
||||||
|
"action": "move",
|
||||||
|
"on_collision": "rename",
|
||||||
|
"quarantine_after": "3",
|
||||||
|
}
|
||||||
|
data.update(kw)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_validation(client: TestClient, mounted: tuple[int, Path, Path]) -> None:
|
||||||
|
mid, _, consume = mounted
|
||||||
|
r = post(
|
||||||
|
client,
|
||||||
|
"/rules/new",
|
||||||
|
rule_form(
|
||||||
|
mid,
|
||||||
|
consume,
|
||||||
|
interval_seconds="5",
|
||||||
|
pattern_type="regex",
|
||||||
|
pattern="([",
|
||||||
|
set_owner="root",
|
||||||
|
dst_path="relativ",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
for msg in ("mindestens 10", "regulärer Ausdruck", "uid:gid", "absoluten Pfad"):
|
||||||
|
assert msg in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_create_dryrun_run(
|
||||||
|
client: TestClient, state: NetMover, mounted: tuple[int, Path, Path]
|
||||||
|
) -> None:
|
||||||
|
mid, remote, consume = mounted
|
||||||
|
(remote / "scan001.pdf").write_text("pdf")
|
||||||
|
(remote / "notiz.txt").write_text("txt")
|
||||||
|
r = post(client, "/rules/new", rule_form(mid, consume))
|
||||||
|
assert r.status_code == 303, r.text
|
||||||
|
with state.session_factory() as s:
|
||||||
|
rid = s.scalars(select(Rule.id)).one()
|
||||||
|
assert state.scheduler.next_run(rid) is not None
|
||||||
|
|
||||||
|
r = post(client, f"/rules/{rid}/dryrun")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "scan001.pdf" in r.text and str(consume / "scan001.pdf") in r.text
|
||||||
|
assert "notiz.txt" not in r.text and "nichts verändert" in r.text
|
||||||
|
assert (remote / "scan001.pdf").exists()
|
||||||
|
|
||||||
|
result = state.runs.execute(rid) # „Jetzt ausführen“ synchron für den Test
|
||||||
|
assert result is not None
|
||||||
|
assert (consume / "scan001.pdf").exists() and not (remote / "scan001.pdf").exists()
|
||||||
|
|
||||||
|
log = client.get("/log").text
|
||||||
|
assert "Scans → Paperless" in log
|
||||||
|
with state.session_factory() as s:
|
||||||
|
run = s.scalars(select(Run).where(Run.dry_run.is_(False))).one()
|
||||||
|
assert run.status is RunStatus.OK
|
||||||
|
items = client.get(f"/log/{run.id}/items").text
|
||||||
|
assert "scan001.pdf" in items and "verschoben" in items
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_run_now_button(
|
||||||
|
client: TestClient, state: NetMover, mounted: tuple[int, Path, Path]
|
||||||
|
) -> None:
|
||||||
|
mid, _, consume = mounted
|
||||||
|
post(client, "/rules/new", rule_form(mid, consume))
|
||||||
|
with state.session_factory() as s:
|
||||||
|
rid = s.scalars(select(Rule.id)).one()
|
||||||
|
r = post(client, f"/rules/{rid}/run")
|
||||||
|
assert r.status_code == 303
|
||||||
|
assert "Lauf gestartet" in client.get("/").text
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_toggle_and_delete_sync_scheduler(
|
||||||
|
client: TestClient, state: NetMover, mounted: tuple[int, Path, Path]
|
||||||
|
) -> None:
|
||||||
|
mid, _, consume = mounted
|
||||||
|
post(client, "/rules/new", rule_form(mid, consume))
|
||||||
|
with state.session_factory() as s:
|
||||||
|
rid = s.scalars(select(Rule.id)).one()
|
||||||
|
post(client, f"/rules/{rid}/toggle")
|
||||||
|
assert state.scheduler.next_run(rid) is None
|
||||||
|
post(client, f"/rules/{rid}/toggle")
|
||||||
|
assert state.scheduler.next_run(rid) is not None
|
||||||
|
post(client, f"/rules/{rid}/delete")
|
||||||
|
assert state.scheduler.next_run(rid) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_page_shows_stale_warning(
|
||||||
|
client: TestClient, state: NetMover, mounted: tuple[int, Path, Path]
|
||||||
|
) -> None:
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from netmover.models import utcnow
|
||||||
|
|
||||||
|
mid, _, consume = mounted
|
||||||
|
post(client, "/rules/new", rule_form(mid, consume))
|
||||||
|
with session_scope(state.session_factory) as s:
|
||||||
|
r = s.scalars(select(Rule)).one()
|
||||||
|
r.created_at = utcnow() - timedelta(hours=1)
|
||||||
|
r.last_success_at = utcnow() - timedelta(minutes=10)
|
||||||
|
state.started_at = utcnow() - timedelta(hours=1)
|
||||||
|
page = client.get("/").text
|
||||||
|
assert "länger als 3 × Intervall" in page and 'class="warn"' in page
|
||||||
|
assert client.get("/healthz").status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
# -- Einstellungen -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_webhook_url_is_masked(client: TestClient, state: NetMover) -> None:
|
||||||
|
from netmover.settings_store import load_settings
|
||||||
|
|
||||||
|
url = "https://ntfy.example/geheimes-topic-token"
|
||||||
|
r = post(
|
||||||
|
client,
|
||||||
|
"/settings",
|
||||||
|
{
|
||||||
|
"retention_days": "14",
|
||||||
|
"notify_mode": "every",
|
||||||
|
"notify_after": "3",
|
||||||
|
"webhook_url": url,
|
||||||
|
"webhook_content_type": "text/plain",
|
||||||
|
"webhook_template": "$message",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 303
|
||||||
|
page = client.get("/settings").text
|
||||||
|
assert "geheimes-topic-token" not in page and "ntfy.example" in page
|
||||||
|
# Maske unverändert zurückschicken → URL bleibt
|
||||||
|
masked = re.search(r'name="webhook_url" value="([^"]*)"', page)
|
||||||
|
assert masked
|
||||||
|
post(
|
||||||
|
client,
|
||||||
|
"/settings",
|
||||||
|
{
|
||||||
|
"retention_days": "14",
|
||||||
|
"notify_mode": "every",
|
||||||
|
"notify_after": "3",
|
||||||
|
"webhook_url": masked.group(1),
|
||||||
|
"webhook_content_type": "text/plain",
|
||||||
|
"webhook_template": "$message",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cfg = load_settings(state.session_factory)
|
||||||
|
assert cfg.webhook_url == url and cfg.retention_days == 14
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_password(client: TestClient) -> None:
|
||||||
|
r = post(
|
||||||
|
client,
|
||||||
|
"/settings/password",
|
||||||
|
{"current": "falsch", "password": "neues-passwort-1", "confirm": "neues-passwort-1"},
|
||||||
|
)
|
||||||
|
assert "falsch" in client.get("/settings").text
|
||||||
|
r = post(
|
||||||
|
client,
|
||||||
|
"/settings/password",
|
||||||
|
{"current": PASSWORD, "password": "neues-passwort-1", "confirm": "neues-passwort-1"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_excludes_secrets_and_import_roundtrip(
|
||||||
|
client: TestClient,
|
||||||
|
state: NetMover,
|
||||||
|
mounted: tuple[int, Path, Path],
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
mid, _, consume = mounted
|
||||||
|
post(client, "/rules/new", rule_form(mid, consume))
|
||||||
|
r = client.get("/settings/export")
|
||||||
|
assert r.status_code == 200 and "attachment" in r.headers["content-disposition"]
|
||||||
|
text = r.text
|
||||||
|
assert MOUNT_FORM["password"] not in text and "scanner" not in text and "secret_enc" not in text
|
||||||
|
data = json.loads(text)
|
||||||
|
assert data["mounts"][0]["name"] == "Synology Scans"
|
||||||
|
assert data["rules"][0]["src_mount"] == "Synology Scans"
|
||||||
|
|
||||||
|
# in eine frische Instanz importieren
|
||||||
|
settings2 = Settings(
|
||||||
|
data_dir=tmp_path / "lib2",
|
||||||
|
run_dir=tmp_path / "run2",
|
||||||
|
mount_base=tmp_path / "mnt2",
|
||||||
|
mount_backend="fake",
|
||||||
|
)
|
||||||
|
state2 = NetMover(settings2, backend=FakeMountBackend(tmp_path / "fake2"))
|
||||||
|
with TestClient(create_app(settings2, state2), follow_redirects=False) as c2:
|
||||||
|
c2.headers["X-Test-CSRF"] = do_setup(c2)
|
||||||
|
r = c2.post(
|
||||||
|
"/settings/import",
|
||||||
|
data={"csrf_token": c2.headers["X-Test-CSRF"]},
|
||||||
|
files={"file": ("export.json", text, "application/json")},
|
||||||
|
)
|
||||||
|
assert r.status_code == 303 and r.headers["location"] == "/settings", r.headers
|
||||||
|
page = c2.get("/settings").text
|
||||||
|
assert "Importiert" in page and "neu eingeben" in page
|
||||||
|
with state2.session_factory() as s:
|
||||||
|
m = s.scalars(select(Mount)).one()
|
||||||
|
assert m.credential is None and m.auto_mount is False
|
||||||
|
rule = s.scalars(select(Rule)).one()
|
||||||
|
assert rule.enabled is False and rule.src_mount_id == m.id
|
||||||
|
# zweiter Import überspringt vorhandene Namen
|
||||||
|
c2.post(
|
||||||
|
"/settings/import",
|
||||||
|
data={"csrf_token": c2.headers["X-Test-CSRF"]},
|
||||||
|
files={"file": ("export.json", text, "application/json")},
|
||||||
|
)
|
||||||
|
assert "Übersprungen" in c2.get("/settings").text
|
||||||
Reference in New Issue
Block a user