"""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("daynivo-kiosk-audit") IDLE_TIMEOUT = 3600 PUBLIC_ENDPOINTS = {"ui.login", "ui.setup", "static"} def svc(): return current_app.extensions["daynivo"] 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", []))