Files
daynivo-kiosk-os/src/daynivo_kiosk/web/views.py
T
rothmanandClaude Opus 5.5 6733aacb6e
CI / test-bookworm (push) Successful in 29s
CI / test-latest (push) Successful in 33s
Rename the Python package to daynivo_kiosk
src/voxip_kiosk -> src/daynivo_kiosk, with every import, the launchers,
install.sh, the Makefile and the manager's gunicorn app path. An upgrade
replaces /usr/lib/voxip-kiosk/python as a whole, so the old package
disappears; tested by upgrading an installation of the previous commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:52:39 +01:00

604 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Routes of the Daynivo Kiosk Manager."""
from __future__ import annotations
import json
import logging
import socket
import time
from typing import Any
from flask import (Blueprint, Response, abort, current_app, flash, g, jsonify, redirect, render_template, request,
session, url_for)
from .. import netconfig, netinfo, services, sysinfo
from ..agent import read_state
from ..updater import read_state as read_update_state
from ..cdp import CDPError
from ..config import ConfigError, load_config, save_config
from ..helper_client import HelperError
from ..validation import ValidationError, mask_url
from . import security
from .auth import password_problem
bp = Blueprint("ui", __name__)
audit_log = logging.getLogger("voxip-kiosk-audit")
IDLE_TIMEOUT = 3600
PUBLIC_ENDPOINTS = {"ui.login", "ui.setup", "static"}
def svc():
return current_app.extensions["voxip"]
def cfg() -> dict[str, Any]:
if "cfg" not in g:
g.cfg = load_config(svc().paths.config_file)
return g.cfg
def client_ip() -> str:
return request.remote_addr or "?"
def audit(event: str, **details: Any) -> None:
record = {"time": int(time.time()), "event": event, "ip": client_ip(), **details}
audit_log.info(json.dumps(record, ensure_ascii=False))
try:
with open(svc().paths.audit_log, "a", encoding="utf-8") as fh:
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
except OSError:
pass
def wants_json() -> bool:
return request.path.startswith("/api/") or request.headers.get("Accept", "").startswith("application/json")
_local_nets_cache: tuple[float, list[str]] = (0.0, [])
def local_networks() -> list[str]:
global _local_nets_cache
now = time.monotonic()
if now - _local_nets_cache[0] > 60:
nets = [a for addrs in sysinfo.ip_addresses().values() for a in addrs]
_local_nets_cache = (now, nets)
return _local_nets_cache[1]
# -- request pipeline ----------------------------------------------------------
@bp.before_app_request
def guard():
if not security.client_allowed(request.remote_addr, cfg()["admin_allow_public"], local_networks()):
abort(403)
if request.endpoint == "static":
return None
creds = svc().credentials
if not creds.is_configured():
if request.endpoint != "ui.setup":
return redirect(url_for("ui.setup"))
elif request.endpoint == "ui.setup":
return redirect(url_for("ui.dashboard"))
if session.get("auth"):
expired = time.time() - session.get("seen", 0) > IDLE_TIMEOUT
if expired or session.get("epoch") != creds.session_epoch():
session.clear()
flash("Je sessie is verlopen. Log opnieuw in.", "info")
if request.endpoint not in PUBLIC_ENDPOINTS and not session.get("auth"):
if wants_json():
return jsonify(error="Niet ingelogd"), 401
return redirect(url_for("ui.login"))
if session.get("auth"):
session["seen"] = int(time.time())
if request.method == "POST" and not security.csrf_valid(request):
audit("csrf_rejected", path=request.path)
if wants_json():
return jsonify(error="Beveiligingstoken ongeldig. Herlaad de pagina."), 400
flash("Beveiligingstoken ongeldig of verlopen. Probeer het opnieuw.", "error")
return redirect(request.path if request.endpoint in PUBLIC_ENDPOINTS else url_for("ui.dashboard")), 303
return None
@bp.after_app_request
def headers(response: Response):
return security.apply_headers(response, secure=current_app.config["SESSION_COOKIE_SECURE"])
@bp.app_context_processor
def template_globals():
return {
"csrf_token": security.csrf_token,
"version": svc().version,
"device_name": cfg()["device_name"],
"hostname": socket.gethostname().split(".")[0],
"logged_in": bool(session.get("auth")),
"admin_port": cfg()["admin_port"],
"admin_scheme": "https" if cfg()["admin_https"] else "http",
}
@bp.app_errorhandler(403)
def forbidden(_):
return render_template("error.html", title="Geen toegang",
message="De beheerinterface is alleen bereikbaar vanaf het lokale netwerk of via VPN."), 403
@bp.app_errorhandler(404)
def not_found(_):
return render_template("error.html", title="Niet gevonden", message="Deze pagina bestaat niet."), 404
@bp.app_errorhandler(413)
def too_large(_):
return render_template("error.html", title="Te groot", message="Het verzoek is te groot."), 413
# -- setup & login --------------------------------------------------------------
@bp.route("/setup", methods=["GET", "POST"])
def setup():
creds = svc().credentials
error = None
if request.method == "POST":
ip = client_ip()
wait = svc().throttle.retry_after(ip)
password = request.form.get("password", "")
if wait:
error = f"Te veel pogingen. Probeer het over {wait} seconden opnieuw."
elif not creds.check_setup_code(request.form.get("setup_code", "")):
svc().throttle.failure(ip)
audit("setup_code_rejected")
error = "De setupcode klopt niet. Je vindt de code op het scherm van de kiosk."
else:
error = password_problem(password, request.form.get("password_confirm", ""))
if error is None:
epoch = creds.set_password(password)
svc().throttle.success(ip)
session.clear()
session.update(auth=True, epoch=epoch, seen=int(time.time()))
session.permanent = True
audit("setup_completed")
flash("Het beheerderswachtwoord is ingesteld. Welkom!", "success")
return redirect(url_for("ui.dashboard"))
return render_template("setup.html", error=error)
@bp.route("/login", methods=["GET", "POST"])
def login():
if session.get("auth"):
return redirect(url_for("ui.dashboard"))
error = None
if request.method == "POST":
ip = client_ip()
wait = svc().throttle.retry_after(ip)
if wait:
error = f"Te veel mislukte pogingen. Probeer het over {wait} seconden opnieuw."
elif svc().credentials.verify(request.form.get("password", "")):
svc().throttle.success(ip)
session.clear()
session.update(auth=True, epoch=svc().credentials.session_epoch(), seen=int(time.time()))
session.permanent = True
audit("login")
return redirect(url_for("ui.dashboard"))
else:
svc().throttle.failure(ip)
audit("login_failed")
error = "Onjuist wachtwoord."
return render_template("login.html", error=error)
@bp.post("/logout")
def logout():
audit("logout")
session.clear()
flash("Je bent uitgelogd.", "info")
return redirect(url_for("ui.login"))
@bp.route("/account/password", methods=["GET", "POST"])
def change_password():
if request.method == "POST":
creds = svc().credentials
if not creds.verify(request.form.get("current_password", "")):
audit("password_change_failed")
flash("Het huidige wachtwoord klopt niet.", "error")
else:
password = request.form.get("password", "")
problem = password_problem(password, request.form.get("password_confirm", ""))
if problem:
flash(problem, "error")
else:
epoch = creds.set_password(password)
session["epoch"] = epoch
audit("password_changed")
flash("Wachtwoord gewijzigd. Andere sessies zijn uitgelogd.", "success")
return redirect(url_for("ui.dashboard"))
return render_template("password.html", active="account")
# -- dashboard -------------------------------------------------------------------
def browser_state(units: dict[str, Any], agent: dict[str, Any]) -> tuple[str, str]:
unit = units.get("browser", {})
if unit.get("status") != "ok":
return unit.get("status", "unknown"), unit.get("label", "Onbekend")
if agent.get("browser") == "unreachable":
return "starting", "Bezig met starten"
page = agent.get("page")
if page == "offline":
return "warning", "Actief — offlinepagina"
if page == "setup":
return "warning", "Actief — wacht op initiële configuratie"
if page in ("start", "blank", "error"):
return "starting", "Pagina wordt geladen"
return "ok", "Actief"
def collect_status() -> dict[str, Any]:
conf = cfg()
paths = svc().paths
agent = read_state(paths.agent_state_file)
units = services.summary()
b_status, b_label = browser_state(units, agent)
mem = sysinfo.memory()
disk = sysinfo.disk("/")
temp = sysinfo.temperature_c()
load = sysinfo.load_average()
cpu = sysinfo.cpu_percent()
throttle = sysinfo.throttled()
reachable = agent.get("target_reachable")
if reachable is None:
net_status, net_label = "unknown", "Nog niet gecontroleerd"
elif reachable:
net_status, net_label = "ok", "Online — kiosk-URL bereikbaar"
else:
net_status, net_label = "error", f"Offline — {agent.get('reason') or 'niet bereikbaar'}"
addresses = sysinfo.ip_addresses()
return {
"browser": {"status": b_status, "label": b_label, "restarts": units.get("browser", {}).get("restarts", "0")},
"agent": units.get("agent", {}),
"url": mask_url(conf["url"]),
"refresh_interval": conf["refresh_interval"],
"uptime": sysinfo.format_duration(sysinfo.uptime_seconds()),
"cpu": f"{cpu:.0f}%".replace(".", ",") if cpu is not None else "–",
"cpu_percent": cpu or 0,
"load": " / ".join(f"{x:.2f}".replace(".", ",") for x in load) if load else "–",
"temperature": f"{temp:.1f} °C".replace(".", ",") if temp is not None else "–",
"temperature_status": "error" if temp and temp >= 80 else "warning" if temp and temp >= 70 else "ok",
"memory": f"{sysinfo.format_bytes(mem['used'])} van {sysinfo.format_bytes(mem['total'])}" if mem else "–",
"memory_percent": mem["percent"] if mem else 0,
"disk": f"{sysinfo.format_bytes(disk['used'])} van {sysinfo.format_bytes(disk['total'])}" if disk else "–",
"disk_percent": disk["percent"] if disk else 0,
"eth0": ", ".join(addresses.get("eth0", [])) or "Niet verbonden",
"wlan0": ", ".join(addresses.get("wlan0", [])) or "Niet verbonden",
"network": {"status": net_status, "label": net_label},
"power": {"status": "error" if throttle and throttle["now"] else "warning" if throttle and throttle["ever"] else "ok",
"label": ", ".join(throttle["flags"]) if throttle and throttle["flags"] else "In orde"} if throttle else None,
"last_error": agent.get("last_error"),
"version": svc().version,
"os": sysinfo.os_release(),
"model": sysinfo.model(),
}
@bp.get("/")
def dashboard():
return render_template("dashboard.html", active="dashboard", s=collect_status())
@bp.get("/api/status")
def api_status():
return jsonify(collect_status())
# -- kiosk -----------------------------------------------------------------------
@bp.get("/kiosk")
def kiosk():
agent = read_state(svc().paths.agent_state_file)
return render_template("kiosk.html", active="kiosk", conf=cfg(), agent=agent,
errors=services.recent_errors(25), masked_url=mask_url(cfg()["url"]),
s={"browser": dict(zip(("status", "label"), browser_state(services.summary(), agent)))})
def _form_bool(name: str) -> bool:
return request.form.get(name) in ("1", "on", "true", "yes")
def _save(updates: dict[str, Any]) -> dict[str, Any] | None:
try:
return save_config(svc().paths.config_file, updates)
except ConfigError as exc:
for message in exc.errors.values():
flash(message, "error")
except OSError as exc:
flash(f"Opslaan mislukt: {exc}", "error")
return None
@bp.post("/kiosk/settings")
def kiosk_settings():
updates = {
"url": request.form.get("url", ""),
"device_name": request.form.get("device_name", ""),
"refresh_interval": request.form.get("refresh_interval", "0"),
"browser_restart": _form_bool("browser_restart"),
"browser_profile": request.form.get("browser_profile", "persistent"),
}
old = cfg()
new = _save(updates)
if new is not None:
changed = sorted(k for k in updates if old.get(k) != new.get(k))
audit("kiosk_settings", changed=changed, url=mask_url(new["url"]))
message = "Instellingen opgeslagen."
if "url" in changed:
message += " De kiosk opent de nieuwe URL binnen enkele seconden."
if "browser_profile" in changed:
message += " Het profieltype geldt na het herstarten van Chromium."
flash(message, "success")
return redirect(url_for("ui.kiosk"))
def _action_response(ok: bool, message: str, back: str):
if wants_json():
return jsonify(ok=ok, message=message), (200 if ok else 500)
flash(message, "success" if ok else "error")
return redirect(back)
@bp.post("/kiosk/reload")
def kiosk_reload():
try:
svc().cdp.reload()
audit("page_reload")
return _action_response(True, "De pagina wordt herladen.", request.referrer or url_for("ui.dashboard"))
except CDPError as exc:
current_app.logger.warning("Herladen mislukt: %s", exc)
return _action_response(
False,
"Herladen mislukt: Chromium reageert niet. Als hij net (her)start, probeer het over een halve minuut opnieuw.",
request.referrer or url_for("ui.dashboard"),
)
def _helper_action(event: str, verb: str, *args: str, back: str):
try:
result = svc().helper.call(verb, *args)
audit(event, **({"args": list(args)} if args else {}))
return _action_response(True, result.get("message", "Uitgevoerd."), back)
except HelperError as exc:
audit(event + "_failed", error=str(exc))
return _action_response(False, str(exc), back)
@bp.post("/kiosk/restart")
def kiosk_restart():
return _helper_action("browser_restart", "restart-browser", back=request.referrer or url_for("ui.dashboard"))
@bp.get("/kiosk/screenshot.jpg")
def kiosk_screenshot():
try:
data = svc().cdp.screenshot(quality=70)
except CDPError as exc:
return Response(f"Screenshot niet beschikbaar: {exc}", status=503, mimetype="text/plain")
return Response(data, mimetype="image/jpeg")
# -- system ----------------------------------------------------------------------
def display_info() -> dict[str, Any]:
try:
return json.loads(svc().paths.display_info_file.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
@bp.get("/system")
def system():
info = display_info()
outputs = info.get("outputs") or []
modes = outputs[0]["modes"] if outputs else []
return render_template("system.html", active="system", conf=cfg(), modes=modes,
output=outputs[0] if outputs else None, s=collect_status(), update=update_view())
def _when(timestamp: Any) -> str | None:
if not isinstance(timestamp, (int, float)):
return None
return time.strftime("%d-%m-%Y %H:%M", time.localtime(timestamp))
def update_view() -> dict[str, Any]:
"""The updater's last result (update.json, written by root) in display form."""
state = read_update_state(svc().paths)
return {
**state,
"checked": _when(state.get("checked_at")),
"finished": _when(state.get("finished_at")),
# A run that started more than two hours ago and never finished is reported as failed.
"running": state.get("status") == "updating" and time.time() - state.get("started_at", 0) < 7200,
}
@bp.post("/system/display")
def system_display():
updates = {
"rotation": request.form.get("rotation", "0"),
"resolution": request.form.get("resolution", "auto"),
"zoom": request.form.get("zoom", "1").replace(",", "."),
"screen_always_on": _form_bool("screen_always_on"),
"screen_off_minutes": request.form.get("screen_off_minutes", "10"),
}
if _save(updates) is None:
return redirect(url_for("ui.system"))
audit("display_settings", **{k: v for k, v in updates.items()})
if _form_bool("apply_now"):
return _helper_action("browser_restart", "restart-browser", back=url_for("ui.system"))
flash("Scherminstellingen opgeslagen. Ze worden actief bij de volgende herstart van Chromium.", "success")
return redirect(url_for("ui.system"))
@bp.post("/system/hostname")
def system_hostname():
return _helper_action("hostname", "set-hostname", request.form.get("hostname", "").strip().lower(), back=url_for("ui.system"))
@bp.post("/system/reboot")
def system_reboot():
return _helper_action("reboot", "reboot", back=url_for("ui.dashboard"))
@bp.post("/system/poweroff")
def system_poweroff():
return _helper_action("poweroff", "poweroff", back=url_for("ui.dashboard"))
@bp.post("/system/admin")
def system_admin():
updates = {
"admin_port": request.form.get("admin_port", "8080"),
"admin_https": _form_bool("admin_https"),
"admin_allow_public": _form_bool("admin_allow_public"),
}
old = cfg()
new = _save(updates)
if new is None:
return redirect(url_for("ui.system"))
audit("admin_settings", **updates)
if any(old[k] != new[k] for k in ("admin_port", "admin_https")):
scheme = "https" if new["admin_https"] else "http"
target = f"{scheme}://{request.host.rsplit(':', 1)[0]}:{new['admin_port']}/"
try:
svc().helper.call("restart-manager")
except HelperError as exc:
flash(f"Opgeslagen, maar herstarten van de beheerinterface mislukte: {exc}", "error")
return redirect(url_for("ui.system"))
return render_template("moved.html", target=target)
flash("Instellingen opgeslagen.", "success")
return redirect(url_for("ui.system"))
@bp.post("/system/updates")
def system_updates():
auto = _form_bool("auto_update")
if _save({"auto_update": auto}) is not None:
audit("update_settings", auto_update=auto)
flash("Automatisch bijwerken staat " + ("aan." if auto else "uit."), "success")
return redirect(url_for("ui.system") + "#updates")
@bp.post("/system/update/check")
def system_update_check():
back = url_for("ui.system") + "#updates"
try:
state = svc().helper.call("update-check", timeout=60).get("update", {})
except HelperError as exc:
return _action_response(False, str(exc), back)
if state.get("check_error"):
return _action_response(False, f"Controleren mislukt: {state['check_error']}", back)
if state.get("available"):
return _action_response(True, f"Versie {state.get('latest')} is beschikbaar.", back)
return _action_response(True, f"Je hebt de nieuwste versie ({state.get('current')}).", back)
@bp.post("/system/update/install")
def system_update_install():
return _helper_action("update_install", "update-install", back=url_for("ui.system") + "#updates")
# -- logs ------------------------------------------------------------------------
@bp.get("/logs")
def logs():
source = request.args.get("source", "kiosk")
if source not in services.LOG_SOURCES:
source = "kiosk"
try:
lines = int(request.args.get("lines", 100))
except ValueError:
lines = 100
priority = request.args.get("priority") or None
text = services.journal(source, lines, priority)
return render_template("logs.html", active="logs", text=text, source=source, lines=lines, priority=priority,
sources=services.LOG_SOURCES, line_options=services.LOG_LINES)
# -- network -----------------------------------------------------------------------
def _pending() -> dict[str, Any] | None:
pending = netinfo.pending_change(svc().paths.network_pending_file)
if pending:
pending = {k: pending.get(k) for k in ("interface", "method", "address", "created", "deadline")}
pending["remaining"] = max(0, int(pending.get("deadline") or 0) - int(time.time()))
return pending
@bp.get("/network")
def network():
return render_template("network.html", active="network", net=netinfo.overview(), pending=_pending(),
rollback_seconds=netconfig.ROLLBACK_SECONDS)
@bp.get("/api/network/pending")
def api_network_pending():
return jsonify(pending=_pending())
@bp.post("/network/apply")
def network_apply():
interface = request.form.get("interface", "")
payload: dict[str, Any] = {
"interface": interface,
"method": request.form.get("method", "dhcp"),
"address": request.form.get("address", ""),
"gateway": request.form.get("gateway", ""),
"dns": request.form.get("dns", ""),
}
if interface == "wlan0":
payload["wifi"] = {
"ssid": request.form.get("ssid", ""),
"psk": request.form.get("psk", ""),
"hidden": _form_bool("hidden"),
"open": _form_bool("open"),
}
try:
netconfig.validate_request(payload) # early feedback; the helper validates again
svc().helper.call("network-apply", payload=payload, timeout=60)
except (ValidationError, HelperError) as exc:
flash(str(exc), "error")
return redirect(url_for("ui.network"))
audit("network_apply", interface=interface, method=payload["method"], address=payload["address"])
flash(f"Netwerkwijziging toegepast. Bevestig binnen {netconfig.ROLLBACK_SECONDS // 60} minuten, anders worden de "
"vorige instellingen automatisch teruggezet.", "warning")
return redirect(url_for("ui.network"))
@bp.post("/network/confirm")
def network_confirm():
return _helper_action("network_confirm", "network-confirm", back=url_for("ui.network"))
@bp.post("/network/rollback")
def network_rollback():
return _helper_action("network_rollback", "network-rollback", back=url_for("ui.network"))
@bp.post("/network/country")
def network_country():
return _helper_action("wifi_country", "set-wifi-country", request.form.get("country", "").strip().upper(), back=url_for("ui.network"))
@bp.post("/api/network/scan")
def network_scan():
try:
result = svc().helper.call("wifi-scan", timeout=60)
except HelperError as exc:
return jsonify(error=str(exc)), 500
return jsonify(networks=result.get("networks", []))