diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..557d4b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +webui/test/fixtures/.fake-state/ diff --git a/Readme.md b/Readme.md index bd6c017..3dabbee 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # Ubuntu Based Kiosk -**Current Version:** 2.13.0 (check script header for latest version) +**Current Version:** 2.17.0 (check script header for latest version) **Built with Claude Sonnet 4.6 AI assistance** **License:** GPL v3 - Keep derivatives open source **Repository:** https://github.com/outis1one/ubuntu-based-kiosk/ @@ -1291,6 +1291,17 @@ terminal menu and the web UI, so they can't drift apart). steps, so any code or hardware-config change picked up by the pull actually takes effect. Also offers an on-demand Electron version check. See "Recent Updates (v2.15.0)" below. +- `webui/` + `menus/addon_webui.sh` — **Web UI** (Addons, installed by + default during provisioning): a small Node/Express app running as a + systemd service under `$KIOSK_USER` with zero ambient `sudo`, giving + a browser-based editor for Sites & Page Timing, Display & Interaction, + and Password Protection & Lockout, plus install/reconfigure for CUPS, + LMS/Squeezelite, and Asterisk Intercom, and Update — each reached + through a narrow, allow-listed root helper + (`webui_write_helper_script`) rather than any ambient privilege on + the service itself. No login of its own, put it behind your own + reverse proxy with Authelia forward-auth if it needs to be reachable + beyond a trusted LAN. See "Recent Updates (v2.17.0)" below. - `install.sh` — entry point for the modular tool, now grouped **Core Settings / Addons / Advanced** like the legacy menu. On a machine with no kiosk installed yet, it provisions one first (see @@ -1346,9 +1357,26 @@ full migration pass. ## Project Status & Future Plans -**Current Version:** 2.15.0 +**Current Version:** 2.17.0 -**Recent Updates (v2.15.0):** +**Recent Updates (v2.17.0):** +- **Web UI now installs by default** during first-time provisioning (fixed port 8090, no prompt) instead of being opt-in — the Addons menu entry still works standalone for reconfiguring the port or reinstalling it on a kiosk provisioned before this change. +- **The web UI can now install/reconfigure CUPS Printing, LMS Server, Squeezelite Player, and Asterisk Intercom, and check for updates** — the same four addons plus Update named directly. Every one of these is the exact same interactive `action_*` function the terminal menu already uses (no prompt/mutation refactor of any addon file), driven by piping the right answers on stdin — the same technique this project's own bash tests already use to drive these functions. +- **Privilege model:** the web service itself still runs as `$KIOSK_USER` with zero ambient `sudo`. A new narrow, allow-listed root helper is the only way it ever gains privilege — reachable only via a single-path passwordless sudo rule (generated and validated with `visudo -c -f` before being installed), and it re-checks its own fixed action allow-list before dispatching anything. Chosen over running the whole service as root after asking directly: since this repo has no login of its own by design, a request that reaches the web UI with no reverse proxy in front is effectively unauthenticated, so the allow-list bounds what that can actually do to five vetted actions, never a root shell. +- Long-running installs stream live output to the browser via Server-Sent Events, one action at a time — a second request while one is in flight gets a clear `409`, never silently queued or dropped. +- **Full visual redesign:** a sidebar shell (Sites/Display/Lockout/Addons/Update) replacing the single scrolling page of three cards, both light and dark themes via `prefers-color-scheme`, no external font/CDN dependency. +- A real bug was found and fixed by actually driving the redesigned UI in a headless browser, not just by reading the code: refreshing an addon's pill/button after a successful install used to rebuild the whole card, which raced (and usually lost to) the success status/log that same job had just written a moment earlier. Fixed to update the pill/buttons in place, leaving the completed job's log exactly as the user left it. +- Uninstall-via-web is deliberately still not offered, for any addon — flagged as needing its own double-confirmation design, not bundled into this pass. WiFi, Timezone, Power/Display/Quiet Hours, Diagnostics, Remote Access, Authelia, Factory Reset, Virtual Consoles, Emergency Hotspot, Clone Settings, and the fleet/multi-kiosk dashboard all remain out of scope for the web UI too — each a named, sequenced follow-up, not an oversight. + +**Previous (v2.16.0):** +- **New: Web UI** (Addons → Web UI) — a small Node/Express app (`webui/`) giving a browser-based editor for Sites & Page Timing, Display & Interaction, and Password Protection & Lockout, the three Core Settings menus that are pure `config.json` read/write with no privileged system mutation involved. Runs as a systemd service under `$KIOSK_USER` (the same user Electron runs as), so it never needs `sudo` — it reads/writes `config.json` with normal filesystem permissions. +- `webui/lib/config.js` re-implements `lib/config.sh`'s exact field list, defaults, and merge-on-save contract in JS — `kiosk-app/main.js` already reads the same `config.json` directly in JS, so this isn't a new pattern — meaning it can never silently clobber fields it doesn't track (Authelia's credentials, the unused quiet-hours fields, etc), the same failure mode previously fixed in `lib/config.sh`'s own history. +- **No login of its own, by design.** Authelia runs elsewhere; the expectation is a reverse proxy (e.g. Caddy) with Authelia forward-auth in front of it, the same way other self-hosted apps get protected — Authelia integration is explicitly out of scope for this repo. Direct LAN access with no proxy in front has no authentication at all — treat it like SSH access to the kiosk. +- Deliberately narrow scope for this first pass: WiFi, Timezone, Power/Display/Quiet Hours, Complete Uninstall, every other addon, and everything in Advanced remain terminal-only — a network-facing process shouldn't be handed `sudo`-level system mutation (netplan, `timedatectl`, `apt`, systemd timers) without a lot more thought than this pass gives it. A "restart kiosk display" action was left out for the same reason — would need a narrow polkit grant, a follow-up. +- Wired into Complete Uninstall (`webui_do_uninstall`) and Clone Settings (addon-presence detection) the same way every other addon is. +- This is the single-kiosk piece of the web-based GUI mentioned in this doc's "Modular Management" notes for a while — a central multi-kiosk fleet dashboard is an intentional follow-up, not part of this pass. + +**Previous (v2.15.0):** - **New: Upgrade** (Advanced → Upgrade) — not a port of the legacy Upgrade, which re-extracted `main.js`/`preload.js`/etc from its own heredocs on every run. `kiosk-app/` and `provision/files/` are real files in this git checkout now, so the modular Upgrade is `git pull` (after confirming a clean working tree, and only as a fast-forward — never an automatic merge) followed by re-running the same packages/kiosk-app/display/firewall/power-management steps `lib/provision.sh` already has for a fresh install, reused rather than reimplemented. Skips the interactive first-run settings wizard and the "reboot now" prompt. - Also offers an on-demand Electron version check regardless of whether there was code to pull (Electron isn't versioned by this repo) — reuses the existing, already-tested `action_update_electron` as-is. - Requires a real git checkout (not the no-git ZIP download option) and a clean working tree; a diverged local history fails the pull cleanly with a clear message rather than attempting an automatic merge. diff --git a/install.sh b/install.sh index b2aef81..4034e87 100755 --- a/install.sh +++ b/install.sh @@ -32,7 +32,12 @@ # (menus/addon_authelia.sh), Remote Access - VNC/WireGuard/ # Tailscale/Netbird (menus/addon_remote_access.sh), LMS Server / # Squeezelite Player (menus/addon_lms_squeezelite.sh), Asterisk -# Intercom - SIP extension client (menus/addon_asterisk_intercom.sh). +# Intercom - SIP extension client (menus/addon_asterisk_intercom.sh), +# Web UI (menus/addon_webui.sh - browser-based editor for Sites/ +# Display/Lockout, the webui/ Node app installed as a systemd +# service; no login of its own, put it behind your own reverse proxy +# with Authelia forward-auth if it needs to be reachable beyond a +# trusted LAN - see the file header). # Advanced: Diagnostics (menus/diagnostics.sh - system status/logs/ # audio/network), Electron Maintenance (menus/advanced_electron.sh - # manual update, fix blank screen), Factory Reset @@ -87,6 +92,8 @@ source "$SCRIPT_DIR/menus/addon_remote_access.sh" source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh" # shellcheck source=menus/addon_asterisk_intercom.sh source "$SCRIPT_DIR/menus/addon_asterisk_intercom.sh" +# shellcheck source=menus/addon_webui.sh +source "$SCRIPT_DIR/menus/addon_webui.sh" # shellcheck source=menus/advanced_electron.sh source "$SCRIPT_DIR/menus/advanced_electron.sh" # shellcheck source=menus/advanced_upgrade.sh @@ -167,8 +174,8 @@ core_settings_menu() { } addons_menu_builder() { - MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player" "Asterisk Intercom (SIP Extension)") - MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu addon_asterisk_intercom_menu) + MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player" "Asterisk Intercom (SIP Extension)" "Web UI") + MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu addon_asterisk_intercom_menu addon_webui_menu) } addons_menu() { diff --git a/lib/config.sh b/lib/config.sh index 28fd411..9c66a5f 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -31,6 +31,15 @@ : "${NETPLAN_DIR:=/etc/netplan}" : "${POLKIT_DIR:=/etc/polkit-1/localauthority/50-local.d}" : "${WIREGUARD_DIR:=/etc/wireguard}" +: "${WEBUI_DIR:=/opt/kiosk-webui}" +: "${WEBUI_ENV_DIR:=/etc/kiosk-webui}" +: "${SUDOERS_D_DIR:=/etc/sudoers.d}" +# The one root-owned script the Web UI's addon-install/Update actions are +# allowed to invoke via passwordless sudo (menus/addon_webui.sh writes it +# and the matching /etc/sudoers.d/kiosk-webui rule - see that file's +# header). Under $BIN_DIR since that's already the convention for every +# other addon's own scripts. +: "${WEBUI_HELPER_PATH:=$BIN_DIR/kiosk-webui-helper}" # The admin account actually running this tool (as opposed to $KIOSK_USER, # the kiosk's own restricted account) - used where an addon needs to grant diff --git a/lib/provision.sh b/lib/provision.sh index 24fc64d..961eff2 100644 --- a/lib/provision.sh +++ b/lib/provision.sh @@ -38,7 +38,8 @@ # which is more risk than this pass should take on. # # Depends on: lib/menu.sh, lib/config.sh, lib/electron.sh being sourced -# first, and every menus/*.sh this calls into for configuration. +# first, and every menus/*.sh this calls into for configuration +# (including menus/addon_webui.sh, for provision_configure_webui below). ################################################################################ PROVISION_FILES="$SCRIPT_DIR/provision/files" @@ -54,7 +55,7 @@ provision_install_file() { } provision_install_packages() { - echo "[1/9] Installing packages..." + echo "[1/10] Installing packages..." sudo apt update sudo apt install -y \ xorg openbox lightdm unclutter screen curl git build-essential \ @@ -79,7 +80,7 @@ provision_install_packages() { } provision_create_kiosk_user() { - echo "[2/9] Creating kiosk user..." + echo "[2/10] Creating kiosk user..." if ! id "$KIOSK_USER" &>/dev/null; then sudo useradd -m -s /bin/bash -G audio,video,input,plugdev,netdev "$KIOSK_USER" echo "$KIOSK_USER:kiosk" | sudo chpasswd @@ -98,7 +99,7 @@ provision_create_kiosk_user() { } provision_install_nodejs() { - echo "[3/9] Installing Node.js..." + echo "[3/10] Installing Node.js..." if ! command -v node &>/dev/null; then curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - sudo apt install -y nodejs @@ -107,7 +108,7 @@ provision_install_nodejs() { } provision_install_app() { - echo "[4/9] Installing kiosk app..." + echo "[4/10] Installing kiosk app..." sudo cp "$KIOSK_APP_SRC"/*.js "$KIOSK_APP_SRC"/*.html "$KIOSK_APP_SRC/package.json" "$KIOSK_APP_SRC/start.sh" "$KIOSK_DIR/" sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR"/*.js "$KIOSK_DIR"/*.html "$KIOSK_DIR/package.json" "$KIOSK_DIR/start.sh" sudo chmod +x "$KIOSK_DIR/start.sh" @@ -150,7 +151,7 @@ EOF } provision_configure_display() { - echo "[5/9] Configuring display (LightDM/Openbox/touch/video)..." + echo "[5/10] Configuring display (LightDM/Openbox/touch/video)..." sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_HOME/.config/openbox" "$KIOSK_HOME/.config/pulse" sudo mkdir -p /etc/X11/xorg.conf.d @@ -183,7 +184,7 @@ provision_configure_display() { } provision_configure_firewall() { - echo "[6/9] Configuring firewall..." + echo "[6/10] Configuring firewall..." sudo ufw --force enable sudo ufw default deny incoming sudo ufw default allow outgoing @@ -192,7 +193,7 @@ provision_configure_firewall() { } provision_configure_power_management() { - echo "[7/9] Configuring power management..." + echo "[7/10] Configuring power management..." sudo mkdir -p /etc/polkit-1/localauthority/50-local.d provision_install_file "etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla" /etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla @@ -226,11 +227,43 @@ provision_configure_power_management() { fi } +# Installed by default now, not opt-in - reuses menus/addon_webui.sh's +# own install helpers directly rather than duplicating them, with a +# fixed default port and no prompt (first-time install already has +# plenty). See that file's header for the privilege model (a narrow +# allow-listed root helper, not the service itself running as root) and +# why this exists at all: browser-based config editing, and - via that +# helper - install/reconfigure for CUPS/LMS/Squeezelite/Asterisk +# Intercom and Update, none of which are reimplemented here. +provision_configure_webui() { + echo "[8/10] Installing Web UI..." + if ! webui_install_app_files; then + log_warning "Web UI install failed - configure later: Addons -> Web UI" + return 0 + fi + + local port="8090" + webui_write_env_file "$port" + webui_write_unit_file + webui_write_helper_script + if ! webui_write_sudoers_file; then + log_warning "Web UI installed but its addon-install/Update helper could not be granted permission - configure later: Addons -> Web UI" + return 0 + fi + + if enable_and_start_units kiosk-webui; then + sudo ufw allow "${port}/tcp" comment 'Kiosk Web UI' 2>/dev/null || true + log_success "Web UI installed: http://$(get_ip_address):${port}" + else + log_warning "Web UI installed but failed to start - check: sudo journalctl -u kiosk-webui -n 50" + fi +} + # Configuration from here on is NOT reimplemented - it's the exact same # Core Settings / Advanced menus this tool already uses to manage a # kiosk after install, called directly instead of duplicated. provision_configure_kiosk_settings() { - echo "[8/9] Configuring kiosk settings..." + echo "[9/10] Configuring kiosk settings..." echo "Core Settings is next - sites, timezone, touch/navigation," echo "password protection, WiFi, and schedules. Skip and configure" echo "later via ./install.sh if you'd rather do this after reboot." @@ -256,7 +289,7 @@ provision_configure_kiosk_settings() { } provision_finish() { - echo "[9/9] Done." + echo "[10/10] Done." echo log_success "Core installation complete!" echo @@ -289,6 +322,9 @@ run_first_time_install() { echo " - Hardware video acceleration" echo " - Audio support (PipeWire)" echo " - Time synchronization (NTP)" + echo " - Web UI (browser-based Sites/Display/Lockout editor, plus" + echo " addon install/reconfigure and Update - no login of its own," + echo " see Addons -> Web UI)" echo echo "OPTIONAL (configure after install, via Addons):" echo " - Lyrion Music Server (LMS) / Squeezelite" @@ -317,6 +353,7 @@ run_first_time_install() { provision_configure_display provision_configure_firewall provision_configure_power_management + provision_configure_webui provision_configure_kiosk_settings provision_finish return 0 diff --git a/menus/addon_webui.sh b/menus/addon_webui.sh new file mode 100644 index 0000000..b454ed3 --- /dev/null +++ b/menus/addon_webui.sh @@ -0,0 +1,353 @@ +#!/bin/bash +################################################################################ +# menus/addon_webui.sh - "Web UI" addon: installs webui/ (a small Node/ +# Express app) as a systemd service running as $KIOSK_USER, giving a +# browser-based editor for Sites & Page Timing, Display & Interaction, +# and Password Protection & Lockout - config.json read/write only, no +# sudo needed, since that file is already owned by $KIOSK_USER. +# +# Installed by default during first-time provisioning now +# (lib/provision.sh's provision_configure_webui, which calls the same +# install helpers this file defines) - this menu remains here for +# reconfiguring the port, restarting the service, or reinstalling it on +# a kiosk provisioned before this change. +# +# Also lets the web UI trigger a fixed, vetted set of privileged actions +# - install/reconfigure CUPS/LMS/Squeezelite/Asterisk Intercom, and +# Update - through a narrow, allow-listed root helper +# (webui_write_helper_script below), rather than by giving the service +# itself any elevated privilege. The helper is reachable only via a +# single-path passwordless sudo rule (webui_write_sudoers_file) and +# re-checks its own fixed action allow-list before dispatching anything, +# even though the sudoers rule alone already restricts which script can +# run - defense in depth. Each allow-listed action is the exact same +# interactive `action_*` function the terminal menu already uses, +# driven by piping the right answers on stdin, the same technique this +# project's own bash tests already use (see webui/lib/actions.js and +# webui/test/*.test.js). No prompt/mutation refactor of any addon file +# was needed for this to work. Everything else (WiFi, Timezone, Power/ +# Display/Quiet Hours, Complete Uninstall, Remote Access, Authelia, +# Factory Reset, Virtual Consoles, Emergency Hotspot, Clone Settings) +# stays terminal-only for now. +# +# No login of its own, by design: this addon assumes it'll be put behind +# a reverse proxy (e.g. Caddy) with Authelia forward-auth in front, the +# same way other self-hosted apps get protected - Authelia integration +# is explicitly out of scope for this repo (Authelia runs elsewhere). +# Direct LAN access with no proxy in front has no authentication at all - +# treat it the same as SSH access to this kiosk, which is also the +# access level the privileged helper effectively grants if reached +# without a proxy in front: bounded to its fixed action list, not a +# root shell, but real system mutation all the same. +# +# webui/'s own app-level logic (config.json schema/merge, API +# validation, the action allow-list/stdin synthesis, the job/SSE +# system) lives and is tested entirely under webui/ - this file only +# wires it up as a system service plus the privileged helper, and never +# touches config.json itself. +# +# Depends on: lib/menu.sh, lib/config.sh being sourced first. +################################################################################ + +webui_is_installed() { + [[ -f "$SYSTEMD_DIR/kiosk-webui.service" ]] +} + +webui_is_active() { + systemctl is-active --quiet kiosk-webui +} + +webui_port() { + if sudo test -f "$WEBUI_ENV_DIR/webui.env" 2>/dev/null; then + sudo grep -oP '^PORT=\K.*' "$WEBUI_ENV_DIR/webui.env" 2>/dev/null || echo "8090" + else + echo "8090" + fi +} + +addon_webui_status() { + if webui_is_installed && webui_is_active; then + echo "Web UI: running at http://$(get_ip_address):$(webui_port)" + elif webui_is_installed; then + echo "Web UI: installed but not running" + else + echo "Web UI: not installed" + fi +} + +addon_webui_menu_builder() { + if webui_is_installed; then + MENU_LABELS=("Change port" "Restart service" "Uninstall") + MENU_HANDLERS=(action_webui_reconfigure action_webui_restart action_webui_uninstall) + else + MENU_LABELS=("Install Web UI") + MENU_HANDLERS=(action_webui_install) + fi +} + +addon_webui_menu() { + run_menu "WEB UI" addon_webui_menu_builder addon_webui_status +} + +################################################################################ +# Shared install helpers +################################################################################ + +# Copies webui/ from this checkout onto the kiosk and installs its npm +# dependencies as $KIOSK_USER - same shape as provision_install_app in +# lib/provision.sh, but addon-scoped (opt-in) rather than core. Every +# critical step is individually guarded (matches menus/addon_cups.sh's +# action_install_cups) rather than relying on set -e to stop a bare +# sequence - menu actions are always invoked via run_menu's +# `"${MENU_HANDLERS[...]}" "$choice" || true` dispatch, which already +# exempts everything they do from set -e for the whole call, so a bare +# unguarded sequence here would silently continue past a real failure +# (e.g. attempting npm install into a directory `cp` never populated). +webui_install_app_files() { + if ! sudo mkdir -p "$WEBUI_DIR"; then + log_error "Could not create $WEBUI_DIR" + return 1 + fi + if ! sudo cp -r "$SCRIPT_DIR/webui/." "$WEBUI_DIR/"; then + log_error "Could not copy Web UI app files" + return 1 + fi + sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$WEBUI_DIR" + if ! sudo -u "$KIOSK_USER" bash -lc "cd '$WEBUI_DIR' && npm install --omit=dev --unsafe-perm"; then + log_error "npm install failed" + return 1 + fi +} + +webui_write_env_file() { + local port="$1" + sudo mkdir -p "$WEBUI_ENV_DIR" + sudo tee "$WEBUI_ENV_DIR/webui.env" > /dev/null < /dev/null <&2 + exit 1 +fi + +"\$action" +EOF + sudo chown root:root "$WEBUI_HELPER_PATH" + sudo chmod 750 "$WEBUI_HELPER_PATH" +} + +# Grants $KIOSK_USER passwordless sudo on exactly this one script path - +# no argument wildcarding at the sudoers level, since the script's own +# ALLOWED_ACTIONS check above is the real gate. Validated with +# `visudo -c -f` on a temp file before it's moved into place: a +# malformed sudoers snippet can break sudo system-wide, so this step is +# never skipped. +webui_write_sudoers_file() { + local tmp + tmp=$(mktemp) + echo "${KIOSK_USER} ALL=(root) NOPASSWD: ${WEBUI_HELPER_PATH}" > "$tmp" + + if ! sudo visudo -c -f "$tmp" &>/dev/null; then + log_error "Generated sudoers rule failed validation - not installed" + rm -f "$tmp" + return 1 + fi + + sudo mkdir -p "$SUDOERS_D_DIR" + sudo install -m 0440 -o root -g root "$tmp" "$SUDOERS_D_DIR/kiosk-webui" + rm -f "$tmp" +} + +webui_write_unit_file() { + sudo tee "$SYSTEMD_DIR/kiosk-webui.service" > /dev/null </dev/null || true + log_success "Web UI installed: http://$(get_ip_address):${port}" + else + log_error "Web UI installed but failed to start - check: sudo journalctl -u kiosk-webui -n 50" + return 1 + fi +} + +action_webui_reconfigure() { + echo + local current_port + current_port=$(webui_port) + local port + port=$(ask_integer "Port to listen on" "$current_port" 1024 65535) + + if [[ "$port" == "$current_port" ]]; then + echo "No change" + return + fi + + webui_write_env_file "$port" + sudo systemctl restart kiosk-webui + sudo ufw allow "${port}/tcp" comment 'Kiosk Web UI' 2>/dev/null || true + log_success "Web UI now listening on port ${port}" +} + +action_webui_restart() { + sudo systemctl restart kiosk-webui + sleep 1 + if webui_is_active; then + log_success "Web UI restarted" + else + log_error "Web UI failed to restart - check: sudo journalctl -u kiosk-webui -n 50" + return 1 + fi +} + +action_webui_uninstall() { + echo + ask_yes_no "Uninstall the Web UI?" "n" || { echo "Cancelled"; return; } + webui_do_uninstall +} + +# The actual removal, no prompt - shared with Complete Uninstall so that +# operation doesn't need to re-implement Web UI teardown a second time. +# No `ufw delete` - matches every other addon's uninstall in this +# codebase, which never removes its own firewall rule either. +webui_do_uninstall() { + sudo systemctl stop kiosk-webui 2>/dev/null || true + sudo systemctl disable kiosk-webui 2>/dev/null || true + sudo rm -f "$SYSTEMD_DIR/kiosk-webui.service" + sudo systemctl daemon-reload 2>/dev/null || true + sudo rm -f "$SUDOERS_D_DIR/kiosk-webui" "$WEBUI_HELPER_PATH" + sudo rm -rf "$WEBUI_DIR" "$WEBUI_ENV_DIR" + log_success "Web UI removed" +} diff --git a/menus/clone_settings.sh b/menus/clone_settings.sh index b61c0b1..9994193 100644 --- a/menus/clone_settings.sh +++ b/menus/clone_settings.sh @@ -64,6 +64,7 @@ clone_detect_addons() { command -v tailscale &>/dev/null && addons+=("tailscale") command -v netbird &>/dev/null && addons+=("netbird") baresip_is_installed 2>/dev/null && addons+=("asterisk_intercom") + webui_is_installed 2>/dev/null && addons+=("webui") if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then local authelia_url diff --git a/menus/complete_uninstall.sh b/menus/complete_uninstall.sh index c013062..53d83a8 100644 --- a/menus/complete_uninstall.sh +++ b/menus/complete_uninstall.sh @@ -7,7 +7,8 @@ # (cups_do_uninstall, vnc_do_uninstall, wireguard_do_uninstall, # tailscale_do_uninstall, netbird_do_uninstall, lms_do_uninstall, # squeezelite_do_uninstall, asterisk_intercom_do_uninstall, -# power_schedule_do_remove_all, emergency_hotspot_do_disable) instead of +# webui_do_uninstall, power_schedule_do_remove_all, +# emergency_hotspot_do_disable) instead of # re-implementing removal logic for each addon a second time here - if an # addon's uninstall logic changes, this picks it up automatically. Only # the pieces no single addon owns - the kiosk user/files, Node.js/ @@ -57,6 +58,7 @@ action_complete_uninstall() { echo " • Squeezelite and LMS (Lyrion Music Server)" echo " • Remote access (VNC, WireGuard, Tailscale, Netbird)" echo " • Asterisk Intercom (Baresip)" + echo " • Web UI" echo " • LightDM and Openbox" echo " • All kiosk schedules and services" echo " • Emergency hotspot configuration" @@ -86,6 +88,7 @@ action_complete_uninstall() { lms_do_uninstall purge squeezelite_do_uninstall asterisk_intercom_do_uninstall purge + webui_do_uninstall echo "[3/12] Removing schedules and emergency hotspot..." power_schedule_do_remove_all diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index a9716bd..a152cf0 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,95 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v2.15.0 ### +### Ubuntu Based Kiosk v2.17.0 ### ################################################################################ # +# RELEASE v2.17.0 - Web UI Now Installs by Default and Can Install/ +# Reconfigure Addons + Check for Updates +# - Web UI (Addons -> Web UI) now installs by default during first-time +# provisioning (lib/provision.sh's provision_configure_webui), not +# opt-in - fixed port 8090, no prompt (matches every other core step). +# The Addons menu entry still works standalone for reconfiguring the +# port or reinstalling it on a kiosk provisioned before this change. +# - The web UI can now install/reconfigure CUPS Printing, LMS Server, +# Squeezelite Player, and Asterisk Intercom, and check for updates - +# the same four addons plus Update this project's user asked for by +# name. Every one of these is the exact same interactive action_* +# function the terminal menu already uses (action_install_cups, +# action_install_lms, action_install_squeezelite, +# action_configure_asterisk_intercom, action_upgrade) - no +# prompt/mutation refactor of any addon file, driven instead by +# piping the right answers on stdin, the same technique this +# project's own bash tests already use to drive these functions. +# - Privilege model: the web service itself still runs as $KIOSK_USER +# with zero ambient sudo. A new narrow, allow-listed root helper +# (menus/addon_webui.sh's webui_write_helper_script, reachable only +# via a single-path passwordless sudo rule generated and validated +# with `visudo -c -f` before being installed) is the only way the web +# UI ever gains privilege, and it re-checks its own fixed action +# allow-list before dispatching anything - a request that reaches it +# can only ever trigger one of five vetted actions, never a root +# shell. Chosen over running the whole service as root after asking +# directly: this repo has no login of its own by design (Authelia +# runs elsewhere), so a request that reaches it with no reverse proxy +# in front is effectively unauthenticated - the allow-list bounds +# what that can actually do. +# - Long-running installs stream live output to the browser via +# Server-Sent Events (webui/lib/jobs.js), with only one action +# running at a time (a second request while one is in flight gets a +# clear 409, not silently queued or dropped). +# - Full visual redesign: a sidebar shell (Sites/Display/Lockout/ +# Addons/Update) replacing the single scrolling page of three cards, +# both light and dark themes via prefers-color-scheme, no external +# font/CDN dependency. +# - A real bug was found and fixed by actually driving the redesigned +# UI in a headless browser, not just by reading the code: refreshing +# an addon's pill/button after a successful install used to rebuild +# the whole card, which raced (and usually lost to) the success +# status/log that same job had just written a moment earlier. Fixed +# to update pill/buttons in place, leaving the completed job's log +# exactly as the user left it. +# - Uninstall-via-web is deliberately still not offered, for any addon - +# flagged as needing its own double-confirmation design, not bundled +# into this pass. WiFi, Timezone, Power/Display/Quiet Hours, +# Diagnostics, Remote Access, Authelia, Factory Reset, Virtual +# Consoles, Emergency Hotspot, Clone Settings, and the fleet/ +# multi-kiosk dashboard all remain out of scope for the web UI too, +# each a named, sequenced follow-up rather than an oversight. +# +# RELEASE v2.16.0 - Web UI: Browser-Based Config Editor (install.sh -> +# Addons -> Web UI) +# - New: a small Node/Express app (webui/) installable via ./install.sh's +# Addons menu, giving a browser-based editor for Sites & Page Timing, +# Display & Interaction, and Password Protection & Lockout - the three +# Core Settings menus that are pure config.json read/write with no +# privileged system mutation involved. Runs as a systemd service under +# $KIOSK_USER (the same user Electron runs as), so it never needs sudo +# - it can read/write config.json directly with normal filesystem +# permissions. webui/lib/config.js re-implements lib/config.sh's exact +# schema and merge-on-save contract in JS (kiosk-app/main.js already +# reads the same file directly in JS - established precedent, not a +# new pattern), so it can never silently clobber fields it doesn't +# track (Authelia's credentials, quiet-hours fields, etc) - the same +# failure mode previously fixed in lib/config.sh's own history. +# - No login of its own, by design - Authelia runs elsewhere, and the +# expectation is a reverse proxy (e.g. Caddy) with Authelia forward- +# auth in front of it, the same way other self-hosted apps get +# protected. Direct LAN access with no proxy in front has no +# authentication at all - treat it like SSH access to the kiosk. +# - Deliberately narrow scope for this first pass: WiFi, Timezone, +# Power/Display/Quiet Hours, Complete Uninstall, every other addon, +# and everything in Advanced remain terminal-only - a network-facing +# process shouldn't be handed sudo-level system mutation (netplan, +# timedatectl, apt, systemd timers) without a lot more thought than +# this pass gives it. A "restart kiosk display" action was left out +# for the same reason - would need a narrow polkit grant, follow-up. +# - Wired into Complete Uninstall (webui_do_uninstall) and Clone Settings +# (webui addon-presence detection) the same way every other addon is. +# - This is the single-kiosk piece of the planned web-based GUI +# (mentioned in this repo's "Modular Management" notes for a while) - +# a central multi-kiosk fleet dashboard is an intentional follow-up, +# not part of this pass. +# # RELEASE v2.15.0 - Upgrade Migrated to install.sh (Advanced -> Upgrade) # - New in ./install.sh's Advanced menu: Upgrade. Not a port of this # script's Upgrade - that one re-extracted main.js/preload.js/etc from @@ -546,7 +633,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="2.15.0" +SCRIPT_VERSION="2.17.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor, diff --git a/webui/lib/actions.js b/webui/lib/actions.js new file mode 100644 index 0000000..8a9476d --- /dev/null +++ b/webui/lib/actions.js @@ -0,0 +1,171 @@ +'use strict'; + +// webui/lib/actions.js - the web UI's allow-list of privileged actions, +// and how to turn a web form's fields into the exact stdin sequence the +// real bash `action_*` function expects. +// +// Mirrors menus/addon_webui.sh's ALLOWED_ACTIONS array (inside the +// generated kiosk-webui-helper script) - kept in sync by hand rather +// than shared/generated, since both lists are short and deliberately +// curated. A mismatch between the two just means an action fails closed +// on whichever side is missing it, never open on both: server.js checks +// this list before spawning anything, and the helper script re-checks +// its own list before dispatching regardless of what server.js sent. +// +// Every buildStdin() below was verified against the real menus/*.sh +// source (prompt order, defaults, and which fields reject a blank +// answer and re-prompt) - see webui/test/actions.test.js, which drives +// the actual bash functions with this exact output and checks the real +// resulting state (cups_is_installed, lms_is_installed, etc), not just +// that the process exits 0. + +const ACTIONS = { + install_cups: { + helperAction: 'action_install_cups', + label: 'Install CUPS Printing', + fields: [], + // action_install_cups's only prompt is "Install CUPS printing? + // (n)" - the web UI's own install button is the confirmation, + // so this always answers yes. No pause() in this function. + buildStdin() { + return 'y\n'; + }, + }, + + reconfigure_cups: { + helperAction: 'action_reconfigure_cups', + label: 'Reconfigure CUPS for network access', + fields: [], + // No prompts at all, no pause(). + buildStdin() { + return ''; + }, + }, + + install_lms: { + helperAction: 'action_install_lms', + label: 'Install / reconfigure LMS Server', + // fields.alreadyInstalled must reflect real current state + // (server.js fills this in from lms_is_installed via the + // helper's own status check before offering the reconfigure + // fields) - action_install_lms branches on it internally and a + // wrong guess here desyncs the stdin sequence from what the + // real function actually prompts for. + fields: ['alreadyInstalled', 'reconfigurePort', 'newPort'], + buildStdin(f) { + if (f.alreadyInstalled) { + if (!f.reconfigurePort) { + return 'n\n\n'; // decline reconfigure, then pause() + } + const port = Number(f.newPort); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('newPort must be an integer 1-65535'); + } + return `y\n${port}\n\n`; // accept, new port, pause() + } + return '\n'; // fresh install: fully automated except pause() + }, + }, + + install_squeezelite: { + helperAction: 'action_install_squeezelite', + label: 'Install / reconfigure Squeezelite Player', + // If already installed, the real function first asks + // "Reconfigure?" (default n) and, if declined, returns + // immediately after just the pause() - it does NOT fall through + // to the player-name/server prompts. f.reconfigure must be + // explicit (not inferred from other fields) so the web UI can + // offer "leave it as-is" without also having to resend the + // current values. + fields: ['alreadyInstalled', 'reconfigure', 'playerName', 'lmsServer'], + buildStdin(f) { + if (f.alreadyInstalled && !f.reconfigure) { + return 'n\n\n'; // decline reconfigure, then pause() + } + const lines = []; + if (f.alreadyInstalled) lines.push('y'); // "Reconfigure?" + lines.push(f.playerName || ''); // blank -> "Kiosk" default + lines.push(f.lmsServer || ''); // blank -> auto-discovery + // "Reboot now?" is always answered "n" here regardless of + // what the UI shows - triggering a real `sudo reboot` from + // inside a one-click addon-install action is out of scope + // for this pass (see webui phase-2 plan). The UI surfaces + // "reboot required to start Squeezelite" as an info banner + // instead of a real remote reboot trigger. + lines.push('n'); + lines.push(''); // pause() + return lines.join('\n') + '\n'; + }, + }, + + configure_asterisk_intercom: { + helperAction: 'action_configure_asterisk_intercom', + label: 'Configure Asterisk Intercom', + // Same shape as Squeezelite's reconfigure gate: if already + // installed, the real function asks "Reconfigure with a + // different server/extension?" (default n) and returns after + // just the pause() if declined - the rest of this sequence is + // never reached in that case. + fields: ['alreadyInstalled', 'reconfigure', 'serverIp', 'serverPort', 'extension', 'password', 'autoAnswer', 'useTls'], + buildStdin(f) { + if (f.alreadyInstalled && !f.reconfigure) { + return 'n\n\n'; // decline reconfigure, then pause() + } + const lines = []; + // Only present at all when baresip_is_installed is already + // true - a fresh install has no "Reconfigure?" prompt. + if (f.alreadyInstalled) lines.push('y'); + + // Server IP and extension reject a blank answer and + // re-prompt (a `while [[ -z ... ]]` loop in the real + // function) - sending an empty line here would desync the + // rest of the sequence by consuming a second prompt cycle, + // so these are validated up front instead. + if (!f.serverIp || !String(f.serverIp).trim()) throw new Error('serverIp is required'); + lines.push(String(f.serverIp).trim()); + + lines.push(f.serverPort != null && f.serverPort !== '' ? String(f.serverPort) : ''); + + if (!f.extension || !String(f.extension).trim()) throw new Error('extension is required'); + lines.push(String(f.extension).trim()); + + // Password also rejects blank and re-prompts, same reason. + if (!f.password) throw new Error('password is required'); + lines.push(f.password); + + lines.push(f.autoAnswer ? 'y' : 'n'); + lines.push(f.useTls ? 'y' : 'n'); + // "Proceed with installation?" (default y) - already + // confirmed by the web click that got us here. + lines.push('y'); + lines.push(''); // pause() + return lines.join('\n') + '\n'; + }, + }, + + upgrade: { + helperAction: 'action_upgrade', + label: 'Check for and apply updates', + fields: [], + buildStdin() { + // action_upgrade's own flow: "Pull these changes...?" (y), + // then - only if there was anything to pull - + // "Restart kiosk display now...?" (y), then always + // "Check for and install the latest Electron...?", answered + // n here. That sub-flow's own prompts default to declining + // and aren't a good fit for one-click automation yet (see + // webui phase-2 plan, "Explicitly deferred"). Answering "n" + // to a prompt that never actually gets shown (nothing to + // pull, or the display-restart question) is harmless - a + // synthesized line bash never reads is simply left unread, + // not an error. + return 'y\ny\nn\n'; + }, + }, +}; + +function getAction(name) { + return Object.prototype.hasOwnProperty.call(ACTIONS, name) ? ACTIONS[name] : undefined; +} + +module.exports = { ACTIONS, getAction }; diff --git a/webui/lib/config.js b/webui/lib/config.js new file mode 100644 index 0000000..e61f63b --- /dev/null +++ b/webui/lib/config.js @@ -0,0 +1,161 @@ +'use strict'; + +// webui/lib/config.js - config.json read/write for the web UI. +// +// Mirrors lib/config.sh's load_existing_config()/save_config() contract +// exactly, in JS instead of jq, so the web UI and the terminal menus stay +// in sync against the same file without either one going through the +// other. kiosk-app/main.js already reads this same config.json directly +// in JS (its own fs.readFileSync/JSON.parse, no bash involved) - this is +// established precedent in this repo, not a new pattern. +// +// Tracks exactly the fields Sites & Page Timing (menus/sites.sh), +// Display & Interaction (menus/display.sh), and Password Protection & +// Lockout (menus/lockout.sh) track. lockoutActiveStart/lockoutActiveEnd +// are deliberately excluded, matching lockout.sh's own header comment: +// "the app doesn't act on them... lib/config.sh just carries whatever is +// already in config.json through unchanged." Anything else present in an +// existing file (autheliaURL, autheliaUsername, +// autheliaEncryptedPassword, lockoutActiveStart/End, or any future field) +// is opaque passthrough data - saveConfig() merges onto it, never +// rebuilds from nothing, so none of it is ever silently deleted. That +// exact failure mode was a real, previously-fixed bug in lib/config.sh's +// own history (see its header/body comments) and must not be +// reintroduced here. + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const CONFIG_PATH = process.env.CONFIG_PATH; +if (!CONFIG_PATH) { + throw new Error("CONFIG_PATH environment variable is required (path to the kiosk app's config.json)"); +} + +const SCALAR_DEFAULTS = { + swipeMode: 'dual', + allowNavigation: 'same-origin', + homeTabIndex: -1, + inactivityTimeout: 120, + enablePauseButton: true, + enableKeyboardButton: true, + enableNavButton: true, + enablePasswordProtection: false, + lockoutTimeout: 0, + lockoutAtTime: '', + requirePasswordOnBoot: false, +}; + +function readExisting() { + try { + const raw = fs.readFileSync(CONFIG_PATH, 'utf8'); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; + } catch (e) { + // Missing file or invalid JSON - same fallback save_config() uses + // in lib/config.sh ("{}" when the file is absent/unparsable). + } + return {}; +} + +function normalizeTab(t) { + return { + url: typeof t.url === 'string' ? t.url : '', + duration: Number.isFinite(Number(t.duration)) ? Number(t.duration) : 0, + username: typeof t.username === 'string' ? t.username : '', + password: typeof t.password === 'string' ? t.password : '', + name: typeof t.name === 'string' ? t.name : '', + }; +} + +function hashPassword(plaintext) { + return crypto.createHash('sha256').update(plaintext, 'utf8').digest('hex'); +} + +// Mirrors load_existing_config(). Never returns a site's Basic Auth +// password or the lockout password hash - both are write-only from here, +// same as the terminal menus, which never display a stored password back +// either (sites.sh's edit_page_status only ever shows the username; +// lockout.sh has no "show current password" path at all). +function loadConfig() { + const existing = readExisting(); + const tabs = Array.isArray(existing.tabs) ? existing.tabs.map(normalizeTab) : []; + + const out = { + tabs: tabs.map((t) => ({ + url: t.url, + duration: t.duration, + username: t.username, + hasPassword: t.password.length > 0, + name: t.name, + })), + }; + for (const [key, def] of Object.entries(SCALAR_DEFAULTS)) { + out[key] = key in existing ? existing[key] : def; + } + out.hasLockoutPassword = typeof existing.lockoutPassword === 'string' && existing.lockoutPassword.length > 0; + out.dualSwipe = out.swipeMode === 'dual'; + return out; +} + +// Mirrors save_config(): merge known fields onto whatever's already on +// disk (see file header). `patch` fields are applied only when present - +// omitting a field means "leave it as it is", so each frontend section +// (Sites / Display / Lockout) can PUT just the fields it owns. +function saveConfig(patch) { + const existing = readExisting(); + const merged = { ...existing }; + + for (const [key, def] of Object.entries(SCALAR_DEFAULTS)) { + if (!(key in merged)) merged[key] = def; + } + for (const key of Object.keys(SCALAR_DEFAULTS)) { + if (key in patch) merged[key] = patch[key]; + } + + // Password: only touched when the caller explicitly provides a new + // plaintext value to hash. Disabling protection clears the whole + // lockout state, mirroring lockout.sh's action_disable_protection() + // exactly (not just the enabled flag - the password/timeout/daily + // lock time too). + if (!('lockoutPassword' in merged)) merged.lockoutPassword = ''; + if (typeof patch.newLockoutPassword === 'string' && patch.newLockoutPassword.length > 0) { + merged.lockoutPassword = hashPassword(patch.newLockoutPassword); + } + if (patch.enablePasswordProtection === false) { + merged.lockoutPassword = ''; + merged.lockoutTimeout = 0; + merged.lockoutAtTime = ''; + merged.requirePasswordOnBoot = false; + } + + if (Array.isArray(patch.tabs)) { + const existingTabs = Array.isArray(existing.tabs) ? existing.tabs.map(normalizeTab) : []; + merged.tabs = patch.tabs.map((t, i) => { + const norm = normalizeTab(t); + if (typeof t.password !== 'string') { + // No new password supplied for this tab - keep whatever + // was already stored at this position (tabs are + // positional, not ID-based, matching the bash arrays). + norm.password = existingTabs[i] ? existingTabs[i].password : ''; + } + return norm; + }); + } else if (!Array.isArray(merged.tabs)) { + merged.tabs = []; + } + + merged.autoswitch = true; + merged.enableTouch = true; + merged.dualSwipe = merged.swipeMode === 'dual'; + + const dir = path.dirname(CONFIG_PATH); + fs.mkdirSync(dir, { recursive: true }); + const tmp = path.join(dir, `.config.json.tmp-${process.pid}-${Date.now()}`); + fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n', { mode: 0o644 }); + fs.renameSync(tmp, CONFIG_PATH); + + return loadConfig(); +} + +module.exports = { loadConfig, saveConfig, CONFIG_PATH, SCALAR_DEFAULTS }; diff --git a/webui/lib/jobs.js b/webui/lib/jobs.js new file mode 100644 index 0000000..a6c1d6e --- /dev/null +++ b/webui/lib/jobs.js @@ -0,0 +1,97 @@ +'use strict'; + +// webui/lib/jobs.js - runs one privileged action at a time via the +// allow-listed root helper (menus/addon_webui.sh's kiosk-webui-helper), +// and keeps an in-memory log so /api/actions/:name/run's caller and any +// number of SSE stream reconnects all see the same output. No database - +// this tool manages one kiosk, a Map is plenty. +// +// HELPER_PATH and SUDO_CMD are both overridable via environment (see +// webui/test/jobs.test.js): tests point HELPER_PATH at a small fake +// script and clear SUDO_CMD, so the real test suite never needs actual +// root or a real addon install - the same principle as every stubbed +// bash test in this project, just on the Node side. + +const { spawn } = require('child_process'); +const { randomUUID } = require('crypto'); +const { getAction } = require('./actions'); + +const HELPER_PATH = process.env.HELPER_PATH || '/usr/local/bin/kiosk-webui-helper'; +const SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'sudo'; + +const jobs = new Map(); +let activeJobId = null; + +function startJob(actionName, fields) { + const action = getAction(actionName); + if (!action) { + const err = new Error(`Unknown action: ${actionName}`); + err.status = 400; + throw err; + } + if (activeJobId) { + const err = new Error('Another action is already running - wait for it to finish first'); + err.status = 409; + throw err; + } + + // buildStdin() validates its own required fields and throws a plain + // Error with a human-readable message on bad input - treated as a + // 400 here, before anything is spawned. + let stdin; + try { + stdin = action.buildStdin(fields || {}); + } catch (e) { + e.status = 400; + throw e; + } + + const jobId = randomUUID(); + const job = { + id: jobId, + name: actionName, + label: action.label, + status: 'running', + log: [], + exitCode: null, + listeners: new Set(), + }; + jobs.set(jobId, job); + activeJobId = jobId; + + const child = SUDO_CMD + ? spawn(SUDO_CMD, [HELPER_PATH, action.helperAction]) + : spawn(HELPER_PATH, [action.helperAction]); + + const appendLine = (chunk) => { + const text = chunk.toString(); + job.log.push(text); + for (const listener of job.listeners) listener(text); + }; + child.stdout.on('data', appendLine); + child.stderr.on('data', appendLine); + + const finish = (status, exitCode) => { + if (job.status !== 'running') return; // 'error' and 'close' can both fire + job.status = status; + job.exitCode = exitCode; + for (const listener of job.listeners) listener(null); + if (activeJobId === jobId) activeJobId = null; + }; + child.on('close', (code) => finish(code === 0 ? 'success' : 'failed', code)); + child.on('error', (err) => { + job.log.push(`\n[error] ${err.message}\n`); + finish('failed', null); + }); + + child.stdin.write(stdin); + child.stdin.end(); + + return job; +} + +function getJob(jobId) { + return jobs.get(jobId); +} + +module.exports = { startJob, getJob }; diff --git a/webui/package-lock.json b/webui/package-lock.json new file mode 100644 index 0000000..2207711 --- /dev/null +++ b/webui/package-lock.json @@ -0,0 +1,828 @@ +{ + "name": "kiosk-webui", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kiosk-webui", + "version": "1.0.0", + "dependencies": { + "express": "^4.19.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..2eda705 --- /dev/null +++ b/webui/package.json @@ -0,0 +1,8 @@ +{ + "name": "kiosk-webui", + "version": "1.0.0", + "main": "server.js", + "dependencies": { + "express": "^4.19.0" + } +} diff --git a/webui/public/app.js b/webui/public/app.js new file mode 100644 index 0000000..705c859 --- /dev/null +++ b/webui/public/app.js @@ -0,0 +1,622 @@ +'use strict'; + +// webui/public/app.js - vanilla JS, no framework/build step. Every +// user-controlled value (site URL/name/username, addon form fields) is +// set via .value or .textContent, never innerHTML, so nothing typed +// into a form can execute as markup - the one new attack surface a +// browser-based config UI has that the terminal menus never did. + +const msgEl = document.getElementById('msg'); +let currentConfig = null; + +function showMessage(text, isError) { + msgEl.textContent = text; + msgEl.hidden = false; + msgEl.className = 'banner ' + (isError ? 'error' : 'success'); + clearTimeout(showMessage._t); + showMessage._t = setTimeout(() => { msgEl.hidden = true; }, 5000); +} + +/* ---------------------------------------------------------------------- */ +/* Sidebar navigation */ +/* ---------------------------------------------------------------------- */ + +document.querySelectorAll('.nav-item').forEach((btn) => { + btn.addEventListener('click', () => { + document.querySelectorAll('.nav-item').forEach((b) => b.classList.remove('active')); + document.querySelectorAll('.page').forEach((p) => p.classList.remove('active')); + btn.classList.add('active'); + document.getElementById(`page-${btn.dataset.page}`).classList.add('active'); + }); +}); + +document.getElementById('brand-sub').textContent = location.host || 'this kiosk'; + +/* ---------------------------------------------------------------------- */ +/* Config API (Sites / Display / Lockout) */ +/* ---------------------------------------------------------------------- */ + +async function apiGet() { + const res = await fetch('/api/config'); + if (!res.ok) throw new Error('Failed to load configuration'); + return res.json(); +} + +async function apiPut(patch) { + const res = await fetch('/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || 'Save failed'); + return data; +} + +/* ---------------------------------------------------------------------- */ +/* Sites & Page Timing */ +/* ---------------------------------------------------------------------- */ + +const sitesList = document.getElementById('sites-list'); +const siteRowTemplate = document.getElementById('site-row-template'); + +function renderSites(tabs) { + sitesList.textContent = ''; + tabs.forEach((tab, idx) => sitesList.appendChild(buildSiteRow(tab, idx))); + if (tabs.length === 0) { + const p = document.createElement('p'); + p.className = 'hint'; + p.textContent = 'No pages configured yet.'; + sitesList.appendChild(p); + } +} + +function buildSiteRow(tab, idx) { + const node = siteRowTemplate.content.firstElementChild.cloneNode(true); + node.dataset.index = String(idx); + + node.querySelector('.site-url').value = tab.url || ''; + node.querySelector('.site-name').value = tab.name || ''; + node.querySelector('.site-duration').value = tab.duration ?? 180; + + const authEnable = node.querySelector('.site-auth-enable'); + const authUser = node.querySelector('.site-auth-username'); + const authState = node.querySelector('.auth-state'); + const hasAuth = !!(tab.username || tab.hasPassword); + authEnable.checked = hasAuth; + authUser.value = tab.username || ''; + authState.textContent = hasAuth ? '(enabled)' : '(disabled)'; + + node.querySelector('.remove-site').addEventListener('click', () => { + node.remove(); + if (!sitesList.querySelector('.site-row')) renderSites([]); + }); + + return node; +} + +document.getElementById('add-site').addEventListener('click', () => { + if (sitesList.querySelector('.hint')) sitesList.textContent = ''; + sitesList.appendChild(buildSiteRow({ url: '', name: '', duration: 180, username: '', hasPassword: false }, sitesList.children.length)); + updateHomeTabOptions(collectTabs()); +}); +document.getElementById('save-sites').addEventListener('click', saveSites); + +function collectTabs() { + return Array.from(sitesList.querySelectorAll('.site-row')).map((row) => { + const tab = { + url: row.querySelector('.site-url').value.trim(), + name: row.querySelector('.site-name').value.trim(), + duration: parseInt(row.querySelector('.site-duration').value, 10), + }; + const authEnabled = row.querySelector('.site-auth-enable').checked; + if (authEnabled) { + tab.username = row.querySelector('.site-auth-username').value; + const newPass = row.querySelector('.site-auth-password').value; + if (newPass) tab.password = newPass; + } else { + tab.username = ''; + tab.password = ''; + } + return tab; + }); +} + +async function saveSites() { + const tabs = collectTabs(); + if (tabs.some((t) => !t.url)) { + showMessage('Every site needs a URL', true); + return; + } + try { + currentConfig = await apiPut({ tabs }); + showMessage('Sites saved'); + renderSites(currentConfig.tabs); + populateAll(currentConfig); + } catch (e) { + showMessage(e.message, true); + } +} + +sitesList.addEventListener('change', () => { updateHomeTabOptions(collectTabs()); }); + +/* ---------------------------------------------------------------------- */ +/* Display & Interaction */ +/* ---------------------------------------------------------------------- */ + +const displayForm = document.getElementById('display-form'); +const homeTabSelect = document.getElementById('home-tab-select'); + +function updateHomeTabOptions(tabs) { + const previous = homeTabSelect.value; + homeTabSelect.textContent = ''; + const disabledOpt = document.createElement('option'); + disabledOpt.value = '-1'; + disabledOpt.textContent = 'Disabled'; + homeTabSelect.appendChild(disabledOpt); + tabs.forEach((tab, idx) => { + const opt = document.createElement('option'); + opt.value = String(idx); + opt.textContent = tab.name || tab.url || `Page ${idx + 1}`; + homeTabSelect.appendChild(opt); + }); + const stillValid = Array.from(homeTabSelect.options).some((o) => o.value === previous); + homeTabSelect.value = stillValid ? previous : '-1'; +} + +displayForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const fd = new FormData(displayForm); + const patch = { + swipeMode: fd.get('swipeMode'), + allowNavigation: fd.get('allowNavigation'), + enablePauseButton: fd.get('enablePauseButton') === 'on', + enableKeyboardButton: fd.get('enableKeyboardButton') === 'on', + enableNavButton: fd.get('enableNavButton') === 'on', + homeTabIndex: parseInt(fd.get('homeTabIndex'), 10), + inactivityTimeoutMinutes: parseInt(fd.get('inactivityTimeoutMinutes'), 10), + }; + try { + currentConfig = await apiPut(patch); + showMessage('Display & Interaction saved'); + populateAll(currentConfig); + } catch (err) { + showMessage(err.message, true); + } +}); + +/* ---------------------------------------------------------------------- */ +/* Password Protection & Lockout */ +/* ---------------------------------------------------------------------- */ + +const lockoutForm = document.getElementById('lockout-form'); +const lockoutEnabled = document.getElementById('lockout-enabled'); +const lockoutFields = document.getElementById('lockout-fields'); +const dailyLockEnabled = document.getElementById('daily-lock-enabled'); +const lockoutAtTime = document.getElementById('lockout-at-time'); +const passwordLabel = document.getElementById('password-label'); + +function refreshLockoutFieldVisibility() { + lockoutFields.hidden = !lockoutEnabled.checked; +} +lockoutEnabled.addEventListener('change', refreshLockoutFieldVisibility); + +dailyLockEnabled.addEventListener('change', () => { + lockoutAtTime.disabled = !dailyLockEnabled.checked; + if (!dailyLockEnabled.checked) lockoutAtTime.value = ''; +}); + +lockoutForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const fd = new FormData(lockoutForm); + const enable = fd.get('enablePasswordProtection') === 'on'; + const newPassword = fd.get('newLockoutPassword') || ''; + const confirmPassword = document.getElementById('lockout-password-confirm').value; + + if (newPassword && newPassword !== confirmPassword) { + showMessage("Passwords don't match", true); + return; + } + if (enable && !newPassword && !(currentConfig && currentConfig.hasLockoutPassword)) { + showMessage('Set a lockout password before enabling password protection', true); + return; + } + + const patch = { enablePasswordProtection: enable }; + if (enable) { + if (newPassword) patch.newLockoutPassword = newPassword; + patch.lockoutTimeoutMinutes = parseInt(fd.get('lockoutTimeoutMinutes'), 10); + patch.lockoutAtTime = dailyLockEnabled.checked ? fd.get('lockoutAtTime') : ''; + patch.requirePasswordOnBoot = fd.get('requirePasswordOnBoot') === 'on'; + } + + try { + currentConfig = await apiPut(patch); + showMessage('Password Protection & Lockout saved'); + populateAll(currentConfig); + lockoutForm.querySelector('[name=newLockoutPassword]').value = ''; + document.getElementById('lockout-password-confirm').value = ''; + } catch (err) { + showMessage(err.message, true); + } +}); + +function populateAll(config) { + displayForm.elements.swipeMode.value = config.swipeMode; + displayForm.elements.allowNavigation.value = config.allowNavigation; + displayForm.elements.enablePauseButton.checked = !!config.enablePauseButton; + displayForm.elements.enableKeyboardButton.checked = !!config.enableKeyboardButton; + displayForm.elements.enableNavButton.checked = !!config.enableNavButton; + updateHomeTabOptions(config.tabs); + homeTabSelect.value = String(config.homeTabIndex); + displayForm.elements.inactivityTimeoutMinutes.value = Math.round(config.inactivityTimeout / 60); + + lockoutEnabled.checked = !!config.enablePasswordProtection; + passwordLabel.textContent = config.hasLockoutPassword ? 'New password (leave blank to keep the current one)' : 'Set lockout password'; + lockoutForm.elements.lockoutTimeoutMinutes.value = config.lockoutTimeout; + dailyLockEnabled.checked = !!config.lockoutAtTime; + lockoutAtTime.disabled = !config.lockoutAtTime; + lockoutAtTime.value = config.lockoutAtTime || ''; + lockoutForm.elements.requirePasswordOnBoot.checked = !!config.requirePasswordOnBoot; + refreshLockoutFieldVisibility(); +} + +/* ---------------------------------------------------------------------- */ +/* Shared: run a privileged action and stream its log via SSE */ +/* ---------------------------------------------------------------------- */ + +// jobPanelEls = { panel, status, log }. Returns a promise resolving to +// {status, exitCode} once the job finishes (or rejects on a request-level +// error before a job even started, e.g. validation). +async function runAction(actionName, fields, jobPanelEls) { + const res = await fetch(`/api/actions/${actionName}/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fields || {}), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || 'Could not start action'); + + jobPanelEls.panel.classList.add('open'); + jobPanelEls.log.textContent = ''; + setJobStatus(jobPanelEls.status, 'running'); + + return new Promise((resolve, reject) => { + const source = new EventSource(`/api/actions/jobs/${body.jobId}/stream`); + source.addEventListener('log', (ev) => { + jobPanelEls.log.textContent += JSON.parse(ev.data); + jobPanelEls.log.scrollTop = jobPanelEls.log.scrollHeight; + }); + source.addEventListener('done', (ev) => { + const result = JSON.parse(ev.data); + setJobStatus(jobPanelEls.status, result.status); + source.close(); + resolve(result); + }); + source.onerror = () => { + source.close(); + reject(new Error('Lost connection to the log stream')); + }; + }); +} + +function setJobStatus(el, status) { + el.className = `job-status ${status}`; + if (status === 'running') { + el.innerHTML = ''; + const spinner = document.createElement('span'); + spinner.className = 'spinner'; + el.appendChild(spinner); + el.appendChild(document.createTextNode('Running')); + } else { + el.textContent = status === 'success' ? 'Success' : 'Failed'; + } +} + +/* ---------------------------------------------------------------------- */ +/* Addons */ +/* ---------------------------------------------------------------------- */ + +const addonsList = document.getElementById('addons-list'); +const addonCardTemplate = document.getElementById('addon-card-template'); + +const ICONS = { + printer: '', + music: '', + speaker: '', + phone: '', +}; + +function pillHtml(state) { + if (state === true) return 'Installed'; + if (state === false) return 'Not installed'; + return 'Unknown'; +} + +let addonStatus = {}; + +async function loadAddonStatus() { + try { + const res = await fetch('/api/addons/status'); + if (res.ok) addonStatus = await res.json(); + } catch (e) { + // leave addonStatus as-is (pills show "Unknown"); not fatal to the page + } +} + +// Keyed by addon, so a single card can be refreshed in place after its +// own job finishes (see refreshAddonCard) without touching the other +// three, and - critically - without recreating the job-panel/log the +// user is currently looking at. An earlier version called the full +// renderAddons() rebuild after every successful job "to update the +// pill"; that raced (and usually lost to) the same success/log state it +// had just written a moment earlier, since rebuilding the whole list +// replaces the job-panel node with a fresh empty one. Caught by an +// actual headless-browser run, not just reading the code - the log +// looked fine reading it, but watching it in Chromium showed the +// "Success" state flash and vanish. +const ADDON_RECIPES = {}; + +function buildAddonCard(recipe) { + ADDON_RECIPES[recipe.key] = recipe; + const node = addonCardTemplate.content.firstElementChild.cloneNode(true); + node.dataset.addon = recipe.key; + fillAddonCard(node, recipe); + return node; +} + +// (Re)fills everything in a card EXCEPT the job-panel/log, which is +// left exactly as it is - so refreshing a card's install-state after a +// job finishes doesn't erase the result the user just watched stream in. +function fillAddonCard(node, { key, title, desc, icon, buildForm }) { + node.querySelector('.addon-icon').innerHTML = ICONS[icon]; + node.querySelector('.addon-name').textContent = title; + node.querySelector('.addon-desc').textContent = desc; + node.querySelector('.pill').outerHTML = pillHtml(addonStatus[key]); + + const actionsEl = node.querySelector('.addon-actions'); + const formEl = node.querySelector('.addon-form'); + actionsEl.textContent = ''; + formEl.textContent = ''; + formEl.className = 'addon-form'; + + const jobPanelEls = { + panel: node.querySelector('.job-panel'), + status: node.querySelector('.job-status'), + log: node.querySelector('.job-log'), + }; + buildForm({ node, actionsEl, formEl, jobPanelEls, installed: addonStatus[key] === true }); +} + +// Called after one addon's own job finishes - refreshes just that +// card's pill/buttons/form (e.g. "Install" -> "Reconfigure") in place. +async function refreshAddonCard(key) { + await loadAddonStatus(); + const node = addonsList.querySelector(`[data-addon="${key}"]`); + if (node) fillAddonCard(node, ADDON_RECIPES[key]); +} + +function addSubmitAction(formEl, jobPanelEls, actionName, collectFields, onDone) { + formEl.addEventListener('submit', async (e) => { + e.preventDefault(); + const submitBtn = formEl.querySelector('button[type=submit]'); + submitBtn.disabled = true; + try { + const result = await runAction(actionName, collectFields(), jobPanelEls); + if (result.status === 'success') { + showMessage('Done'); + if (onDone) await onDone(); + } else { + showMessage('Action failed - see the log below', true); + } + } catch (err) { + showMessage(err.message, true); + } finally { + submitBtn.disabled = false; + } + }); +} + +function renderAddons() { + addonsList.textContent = ''; + + // CUPS: no fields at all - the button itself is the whole form. + addonsList.appendChild(buildAddonCard({ + key: 'cups', title: 'CUPS Printing', desc: 'Network printer sharing', icon: 'printer', + buildForm({ actionsEl, formEl, jobPanelEls, installed }) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = installed ? 'Reconfigure for network access' : 'Install CUPS Printing'; + btn.addEventListener('click', async () => { + btn.disabled = true; + try { + const action = installed ? 'reconfigure_cups' : 'install_cups'; + const result = await runAction(action, {}, jobPanelEls); + if (result.status === 'success') { showMessage('Done'); await refreshAddonCard('cups'); } + else showMessage('Action failed - see the log below', true); + } catch (err) { + showMessage(err.message, true); + } finally { + btn.disabled = false; + } + }); + actionsEl.appendChild(btn); + }, + })); + + // LMS Server: fresh install has no fields; once installed, an + // optional port-reconfigure field. + addonsList.appendChild(buildAddonCard({ + key: 'lms', title: 'LMS Server', desc: 'Lyrion / Logitech Media Server', icon: 'music', + buildForm({ actionsEl, formEl, jobPanelEls, installed }) { + if (!installed) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = 'Install LMS Server'; + btn.addEventListener('click', async () => { + btn.disabled = true; + try { + const result = await runAction('install_lms', { alreadyInstalled: false }, jobPanelEls); + if (result.status === 'success') { showMessage('Done'); await refreshAddonCard('lms'); } + else showMessage('Action failed - see the log below', true); + } catch (err) { + showMessage(err.message, true); + } finally { + btn.disabled = false; + } + }); + actionsEl.appendChild(btn); + return; + } + const toggleBtn = document.createElement('button'); + toggleBtn.type = 'button'; + toggleBtn.className = 'secondary'; + toggleBtn.textContent = 'Reconfigure port'; + toggleBtn.addEventListener('click', () => formEl.classList.toggle('open')); + actionsEl.appendChild(toggleBtn); + + const portLabel = document.createElement('label'); + portLabel.innerHTML = 'New HTTP port'; + const portInput = document.createElement('input'); + portInput.type = 'number'; portInput.min = '1'; portInput.max = '65535'; portInput.value = '9000'; + portLabel.appendChild(portInput); + formEl.appendChild(portLabel); + const submitBtn = document.createElement('button'); + submitBtn.type = 'submit'; + submitBtn.textContent = 'Apply new port'; + formEl.appendChild(submitBtn); + + addSubmitAction(formEl, jobPanelEls, 'install_lms', () => ({ + alreadyInstalled: true, reconfigurePort: true, newPort: parseInt(portInput.value, 10), + }), () => refreshAddonCard('lms')); + }, + })); + + // Squeezelite: player name + LMS server, both for install and + // reconfigure - the form is the same either way. + addonsList.appendChild(buildAddonCard({ + key: 'squeezelite', title: 'Squeezelite Player', desc: 'Turns this kiosk into an LMS-connected speaker', icon: 'speaker', + buildForm({ actionsEl, formEl, jobPanelEls, installed }) { + formEl.classList.add('open'); + + const nameLabel = document.createElement('label'); + nameLabel.textContent = 'Player name'; + const nameInput = document.createElement('input'); + nameInput.type = 'text'; nameInput.value = 'Kiosk'; nameInput.placeholder = 'Kiosk'; + nameLabel.appendChild(nameInput); + formEl.appendChild(nameLabel); + + const serverLabel = document.createElement('label'); + serverLabel.innerHTML = 'LMS server (IP:PORT, blank for auto-discovery)'; + const serverInput = document.createElement('input'); + serverInput.type = 'text'; serverInput.placeholder = '192.168.1.100:3483'; + serverLabel.appendChild(serverInput); + formEl.appendChild(serverLabel); + + const rebootHint = document.createElement('p'); + rebootHint.className = 'hint'; + rebootHint.textContent = 'A reboot is required after install/reconfigure before Squeezelite starts.'; + formEl.appendChild(rebootHint); + + const submitBtn = document.createElement('button'); + submitBtn.type = 'submit'; + submitBtn.textContent = installed ? 'Reconfigure Squeezelite' : 'Install Squeezelite'; + formEl.appendChild(submitBtn); + + addSubmitAction(formEl, jobPanelEls, 'install_squeezelite', () => ({ + alreadyInstalled: installed, reconfigure: true, + playerName: nameInput.value.trim(), lmsServer: serverInput.value.trim(), + }), () => refreshAddonCard('squeezelite')); + }, + })); + + // Asterisk Intercom: server/extension/password/options, always shown. + addonsList.appendChild(buildAddonCard({ + key: 'asterisk_intercom', title: 'Asterisk Intercom', desc: 'SIP extension client (Baresip)', icon: 'phone', + buildForm({ actionsEl, formEl, jobPanelEls, installed }) { + formEl.classList.add('open'); + + const mk = (label, type, opts) => { + const l = document.createElement('label'); + l.textContent = label; + const i = document.createElement('input'); + i.type = type; + Object.assign(i, opts || {}); + l.appendChild(i); + formEl.appendChild(l); + return i; + }; + const ip = mk('Server IP or hostname', 'text', { placeholder: '10.0.0.5' }); + const port = mk('Server port', 'number', { placeholder: '5060', min: '1', max: '65535' }); + const ext = mk('Extension number', 'text', { placeholder: '201' }); + const pass = mk('SIP password', 'password', { autocomplete: 'new-password' }); + + const autoAnswerLabel = document.createElement('label'); + autoAnswerLabel.className = 'checkbox'; + const autoAnswer = document.createElement('input'); + autoAnswer.type = 'checkbox'; + autoAnswerLabel.appendChild(autoAnswer); + autoAnswerLabel.appendChild(document.createTextNode('Auto-answer incoming calls (intercom mode)')); + formEl.appendChild(autoAnswerLabel); + + const tlsLabel = document.createElement('label'); + tlsLabel.className = 'checkbox'; + const useTls = document.createElement('input'); + useTls.type = 'checkbox'; + tlsLabel.appendChild(useTls); + tlsLabel.appendChild(document.createTextNode('Use TLS encryption')); + formEl.appendChild(tlsLabel); + + const submitBtn = document.createElement('button'); + submitBtn.type = 'submit'; + submitBtn.textContent = installed ? 'Reconfigure Asterisk Intercom' : 'Connect to Asterisk server'; + formEl.appendChild(submitBtn); + + addSubmitAction(formEl, jobPanelEls, 'configure_asterisk_intercom', () => ({ + alreadyInstalled: installed, reconfigure: true, + serverIp: ip.value.trim(), serverPort: port.value ? parseInt(port.value, 10) : undefined, + extension: ext.value.trim(), password: pass.value, + autoAnswer: autoAnswer.checked, useTls: useTls.checked, + }), () => refreshAddonCard('asterisk_intercom')); + }, + })); +} + +/* ---------------------------------------------------------------------- */ +/* Update */ +/* ---------------------------------------------------------------------- */ + +document.getElementById('run-upgrade').addEventListener('click', async (e) => { + const btn = e.currentTarget; + btn.disabled = true; + try { + const result = await runAction('upgrade', {}, { + panel: document.getElementById('job-panel-upgrade'), + status: document.getElementById('job-status-upgrade'), + log: document.getElementById('job-log-upgrade'), + }); + showMessage(result.status === 'success' ? 'Update finished' : 'Update failed - see the log below', result.status !== 'success'); + } catch (err) { + showMessage(err.message, true); + } finally { + btn.disabled = false; + } +}); + +/* ---------------------------------------------------------------------- */ +/* Init */ +/* ---------------------------------------------------------------------- */ + +async function init() { + try { + currentConfig = await apiGet(); + renderSites(currentConfig.tabs); + populateAll(currentConfig); + } catch (e) { + showMessage(e.message, true); + } + await loadAddonStatus(); + renderAddons(); +} + +init(); diff --git a/webui/public/index.html b/webui/public/index.html new file mode 100644 index 0000000..2b3d183 --- /dev/null +++ b/webui/public/index.html @@ -0,0 +1,240 @@ + + + + + +Kiosk Web UI + + + +
+ + +
+ + + +
+ +
+
+
+ + +
+
+
+ + +
+ +
+
+ + + + + + + + +
+ Home page + + +
+ +
+
+
+
+ + +
+ +
+
+ + +
+ + + + + + + + + +
+ +
+
+
+
+ + +
+ +
+
+ + +
+ +
+
+
+
+ Update + +
+

+        
+
+
+
+
+ + + + + + + + diff --git a/webui/public/style.css b/webui/public/style.css new file mode 100644 index 0000000..a540215 --- /dev/null +++ b/webui/public/style.css @@ -0,0 +1,395 @@ +/* Design tokens - light by default, dark via prefers-color-scheme. + No external font/CDN dependency (self-hosted admin tool shouldn't + phone out to Google Fonts) - a well-tuned system-ui stack plus real + spacing/depth/motion is what actually reads as "modern", not the + typeface. */ + +:root { + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", ui-sans-serif, Roboto, Helvetica, Arial, sans-serif; + --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace; + + --bg: #f5f6f8; + --bg-elevated: #ffffff; + --bg-sunken: #eef0f3; + --border: #e2e5ea; + --border-strong: #cbd0d8; + --text: #14161a; + --text-dim: #5c6370; + --text-faint: #8b929e; + --accent: #3b6ff0; + --accent-hover: #2f5cd6; + --accent-text: #ffffff; + --accent-soft: #e8effe; + --success: #1c8a5b; + --success-soft: #e3f6ec; + --warning: #a9660a; + --warning-soft: #fdf1de; + --danger: #d13a3a; + --danger-soft: #fbe8e8; + --shadow-sm: 0 1px 2px rgba(20, 22, 26, 0.06); + --shadow-md: 0 4px 16px rgba(20, 22, 26, 0.08); + --radius: 10px; + --radius-lg: 14px; + --sidebar-w: 232px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #101216; + --bg-elevated: #17191f; + --bg-sunken: #0c0d10; + --border: #262a33; + --border-strong: #363c48; + --text: #eceef2; + --text-dim: #9aa1ad; + --text-faint: #6b7280; + --accent: #5b8cff; + --accent-hover: #7ba0ff; + --accent-text: #0a0e18; + --accent-soft: #17233f; + --success: #3ecf8e; + --success-soft: #103527; + --warning: #e2a53f; + --warning-soft: #3a2c11; + --danger: #f0605f; + --danger-soft: #3a1616; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4); + } +} + +* { box-sizing: border-box; } +::selection { background: var(--accent-soft); } + +html, body { + height: 100%; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 14.5px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + +a { color: var(--accent); } + +/* ---------------------------------------------------------------- */ +/* Shell: fixed sidebar + main content */ +/* ---------------------------------------------------------------- */ + +.shell { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: var(--sidebar-w); + flex-shrink: 0; + background: var(--bg-elevated); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + padding: 20px 12px; + position: sticky; + top: 0; + height: 100vh; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 10px 22px; +} + +.brand-mark { + width: 30px; + height: 30px; + border-radius: 8px; + background: linear-gradient(135deg, var(--accent), var(--accent-hover)); + display: flex; + align-items: center; + justify-content: center; + color: var(--accent-text); + flex-shrink: 0; +} +.brand-mark svg { width: 17px; height: 17px; } + +.brand-text { + display: flex; + flex-direction: column; + line-height: 1.25; + min-width: 0; +} +.brand-text strong { font-size: 14px; } +.brand-text span { font-size: 11.5px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.nav { display: flex; flex-direction: column; gap: 2px; } + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 10px; + border-radius: 8px; + color: var(--text-dim); + font-size: 13.5px; + font-weight: 500; + cursor: pointer; + border: none; + background: transparent; + text-align: left; + width: 100%; +} +.nav-item svg { width: 17px; height: 17px; flex-shrink: 0; opacity: 0.85; } +.nav-item:hover { background: var(--bg-sunken); color: var(--text); } +.nav-item.active { background: var(--accent-soft); color: var(--accent); } +.nav-item.active svg { opacity: 1; } + +.sidebar-footer { + margin-top: auto; + padding: 10px; + font-size: 11.5px; + color: var(--text-faint); + border-top: 1px solid var(--border); + padding-top: 14px; +} + +.main { + flex: 1; + min-width: 0; + padding: 32px 40px 60px; + max-width: 880px; +} + +.page { display: none; } +.page.active { display: block; } + +.page-header { margin-bottom: 24px; } +.page-header h1 { margin: 0 0 4px; font-size: 20px; letter-spacing: -0.01em; } +.page-header p { margin: 0; color: var(--text-dim); font-size: 13.5px; } + +/* ---------------------------------------------------------------- */ +/* Cards, forms, buttons */ +/* ---------------------------------------------------------------- */ + +.card { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: 20px 22px; + margin-bottom: 18px; +} + +.card h2 { margin: 0 0 14px; font-size: 14.5px; font-weight: 600; } + +.card-inset { + background: var(--bg-sunken); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px; + margin-bottom: 10px; +} + +form { display: flex; flex-direction: column; gap: 14px; } + +label { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 12.5px; + font-weight: 600; + color: var(--text-dim); +} + +label.checkbox { + flex-direction: row; + align-items: center; + gap: 8px; + font-weight: 500; + color: var(--text); +} +label.checkbox input { width: 16px; height: 16px; accent-color: var(--accent); } + +.hint { color: var(--text-faint); font-size: 11.5px; font-weight: normal; } + +input[type="text"], +input[type="password"], +input[type="number"], +input[type="time"], +select { + background: var(--bg); + border: 1px solid var(--border-strong); + border-radius: 8px; + color: var(--text); + padding: 8px 10px; + font-size: 13.5px; + font-family: inherit; + transition: border-color 0.12s ease; +} +input:focus, select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +fieldset { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px 16px; + display: flex; + flex-direction: column; + gap: 14px; + margin: 0; +} +legend { padding: 0 6px; color: var(--text-dim); font-size: 12px; font-weight: 600; } + +button { + border: none; + border-radius: 8px; + padding: 8px 15px; + font-size: 13px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + background: var(--accent); + color: var(--accent-text); + align-self: flex-start; + transition: background-color 0.12s ease, transform 0.05s ease; +} +button:hover { background: var(--accent-hover); } +button:active { transform: translateY(1px); } +button:disabled { opacity: 0.55; cursor: not-allowed; } + +button.secondary { + background: transparent; + border: 1px solid var(--border-strong); + color: var(--text); +} +button.secondary:hover { background: var(--bg-sunken); } + +button.danger { + background: transparent; + border: 1px solid var(--danger); + color: var(--danger); +} +button.danger:hover { background: var(--danger-soft); } + +.button-row { display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; } + +/* ---------------------------------------------------------------- */ +/* Addon cards, status pills, job log panel */ +/* ---------------------------------------------------------------- */ + +.addon-card { display: flex; flex-direction: column; gap: 12px; } +.addon-card-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.addon-title { display: flex; align-items: center; gap: 10px; } +.addon-icon { + width: 34px; height: 34px; border-radius: 9px; + background: var(--bg-sunken); display: flex; align-items: center; justify-content: center; + color: var(--text-dim); flex-shrink: 0; +} +.addon-icon svg { width: 18px; height: 18px; } +.addon-name { font-weight: 600; font-size: 14px; } +.addon-desc { color: var(--text-faint); font-size: 12px; margin-top: 1px; } + +.pill { + display: inline-flex; align-items: center; gap: 5px; + padding: 3px 9px; border-radius: 999px; + font-size: 11px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; +} +.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; } +.pill.installed { background: var(--success-soft); color: var(--success); } +.pill.installed::before { background: var(--success); } +.pill.not-installed { background: var(--bg-sunken); color: var(--text-faint); } +.pill.not-installed::before { background: var(--text-faint); } +.pill.unknown { background: var(--warning-soft); color: var(--warning); } +.pill.unknown::before { background: var(--warning); } + +.addon-form { display: none; } +.addon-form.open { display: flex; padding-top: 4px; border-top: 1px solid var(--border); margin-top: 4px; } + +.job-panel { + display: none; + background: var(--bg-sunken); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} +.job-panel.open { display: block; } +.job-panel-header { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 14px; border-bottom: 1px solid var(--border); + font-size: 12.5px; font-weight: 600; +} +.job-status { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.02em; } +.job-status.running { color: var(--accent); } +.job-status.success { color: var(--success); } +.job-status.failed { color: var(--danger); } +.spinner { + width: 12px; height: 12px; border-radius: 50%; + border: 2px solid var(--accent-soft); border-top-color: var(--accent); + animation: spin 0.7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.job-log { + margin: 0; padding: 12px 14px; + font-family: var(--font-mono); font-size: 12px; line-height: 1.6; + color: var(--text-dim); + max-height: 320px; overflow-y: auto; + white-space: pre-wrap; word-break: break-word; +} + +/* ---------------------------------------------------------------- */ +/* Site rows (Sites & Page Timing) */ +/* ---------------------------------------------------------------- */ + +.site-row-grid { + display: grid; + grid-template-columns: 2fr 1fr 1fr; + gap: 12px; +} +@media (max-width: 640px) { .site-row-grid { grid-template-columns: 1fr; } } + +.site-auth { + margin-top: 10px; + border-top: 1px solid var(--border); + padding-top: 10px; +} +.site-auth summary { cursor: pointer; color: var(--text-dim); font-size: 12px; font-weight: 600; } +.site-auth[open] summary { margin-bottom: 10px; } +.auth-state { color: var(--text-faint); font-weight: normal; } + +/* ---------------------------------------------------------------- */ +/* Toast banner */ +/* ---------------------------------------------------------------- */ + +.banner { + border-radius: var(--radius); + padding: 11px 15px; + font-size: 13px; + font-weight: 500; + position: fixed; + top: 18px; + right: 18px; + max-width: 360px; + box-shadow: var(--shadow-md); + z-index: 50; +} +.banner.success { background: var(--success-soft); border: 1px solid var(--success); color: var(--success); } +.banner.error { background: var(--danger-soft); border: 1px solid var(--danger); color: var(--danger); } + +@media (max-width: 800px) { + .shell { flex-direction: column; } + .sidebar { width: 100%; height: auto; position: static; flex-direction: row; align-items: center; padding: 12px; overflow-x: auto; } + .brand { padding: 0 10px 0 0; } + .nav { flex-direction: row; } + .sidebar-footer { display: none; } + .main { padding: 20px; } +} diff --git a/webui/server.js b/webui/server.js new file mode 100644 index 0000000..e70c0f2 --- /dev/null +++ b/webui/server.js @@ -0,0 +1,222 @@ +'use strict'; + +// webui/server.js - Kiosk Web UI: browser-based config editor for Sites & +// Page Timing, Display & Interaction, and Password Protection & Lockout - +// the three Core Settings menus that are pure config.json read/write with +// no privileged system mutation involved (see menus/addon_webui.sh's +// header for why the rest of Core Settings/Addons/Advanced aren't here). +// +// No login of its own by design: Authelia runs elsewhere, and the admin +// site goes behind the user's own Caddy reverse proxy with Authelia +// forward-auth in front of it, the same way every other self-hosted app +// they run is protected. This process only binds where it's told to +// (BIND_ADDR/PORT below) and trusts whatever's in front of it. +// +// Runs as $KIOSK_USER (see the systemd unit menus/addon_webui.sh +// installs) - the same user Electron runs as, and the owner of +// config.json - so it never needs sudo. + +const path = require('path'); +const { execFile } = require('child_process'); +const express = require('express'); +const { loadConfig, saveConfig } = require('./lib/config'); +const { ACTIONS } = require('./lib/actions'); +const { startJob, getJob } = require('./lib/jobs'); + +const HELPER_PATH = process.env.HELPER_PATH || '/usr/local/bin/kiosk-webui-helper'; +const SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'sudo'; + +const app = express(); +app.use(express.json({ limit: '256kb' })); +app.use(express.static(path.join(__dirname, 'public'))); + +const SWIPE_MODES = ['dual', 'standard']; +const NAV_MODES = ['restricted', 'same-origin', 'open']; +const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; + +// Same normalization rule as menus/sites.sh's sites_parse_url(): bare +// host -> https://, bare IPv4 -> http://, else passed through as-is. +function parseUrl(raw) { + if (/^https?:\/\//.test(raw)) return raw; + if (/^\d+\.\d+\.\d+\.\d+/.test(raw)) return `http://${raw}`; + return `https://${raw}`; +} + +function badRequest(res, message) { + res.status(400).json({ error: message }); +} + +app.get('/api/config', (req, res) => { + res.json(loadConfig()); +}); + +app.put('/api/config', (req, res) => { + const body = req.body && typeof req.body === 'object' ? req.body : {}; + const patch = {}; + + if (body.swipeMode !== undefined) { + if (!SWIPE_MODES.includes(body.swipeMode)) return badRequest(res, 'swipeMode must be "dual" or "standard"'); + patch.swipeMode = body.swipeMode; + } + if (body.allowNavigation !== undefined) { + if (!NAV_MODES.includes(body.allowNavigation)) { + return badRequest(res, 'allowNavigation must be "restricted", "same-origin", or "open"'); + } + patch.allowNavigation = body.allowNavigation; + } + if (body.enablePauseButton !== undefined) patch.enablePauseButton = !!body.enablePauseButton; + if (body.enableKeyboardButton !== undefined) patch.enableKeyboardButton = !!body.enableKeyboardButton; + if (body.enableNavButton !== undefined) patch.enableNavButton = !!body.enableNavButton; + + let tabCount; + if (body.tabs !== undefined) { + if (!Array.isArray(body.tabs)) return badRequest(res, 'tabs must be an array'); + for (const t of body.tabs) { + if (!t || typeof t.url !== 'string' || t.url.trim() === '') return badRequest(res, 'Every site needs a URL'); + const dur = Number(t.duration); + if (!Number.isInteger(dur) || dur < -1 || dur > 86400) { + return badRequest(res, 'Duration must be a whole number between -1 and 86400'); + } + } + patch.tabs = body.tabs.map((t) => ({ ...t, url: parseUrl(t.url.trim()) })); + tabCount = patch.tabs.length; + } + + if (body.homeTabIndex !== undefined) { + const idx = Number(body.homeTabIndex); + const count = tabCount !== undefined ? tabCount : loadConfig().tabs.length; + if (!Number.isInteger(idx) || idx < -1 || idx >= count) return badRequest(res, 'homeTabIndex is out of range'); + patch.homeTabIndex = idx; + } + + if (body.inactivityTimeoutMinutes !== undefined) { + const min = Number(body.inactivityTimeoutMinutes); + if (!Number.isInteger(min) || min < 1 || min > 240) return badRequest(res, 'Inactivity timeout must be 1-240 minutes'); + patch.inactivityTimeout = min * 60; + } + + if (body.enablePasswordProtection !== undefined) patch.enablePasswordProtection = !!body.enablePasswordProtection; + + if (body.lockoutTimeoutMinutes !== undefined) { + const min = Number(body.lockoutTimeoutMinutes); + if (!Number.isInteger(min) || min < 0 || min > 1440) return badRequest(res, 'Lockout timeout must be 0-1440 minutes'); + patch.lockoutTimeout = min; + } + + if (body.lockoutAtTime !== undefined) { + if (body.lockoutAtTime !== '' && !TIME_RE.test(body.lockoutAtTime)) { + return badRequest(res, 'lockoutAtTime must be HH:MM (24-hour) or empty'); + } + patch.lockoutAtTime = body.lockoutAtTime; + } + + if (body.requirePasswordOnBoot !== undefined) patch.requirePasswordOnBoot = !!body.requirePasswordOnBoot; + + if (body.newLockoutPassword !== undefined) { + if (typeof body.newLockoutPassword !== 'string' || body.newLockoutPassword.length === 0) { + return badRequest(res, 'Password cannot be empty'); + } + patch.newLockoutPassword = body.newLockoutPassword; + } + + // Mirrors action_enable_protection() always requiring a password up + // front - lockout.sh has no path that enables protection without one. + if (patch.enablePasswordProtection === true) { + const hasNewPassword = typeof patch.newLockoutPassword === 'string' && patch.newLockoutPassword.length > 0; + if (!hasNewPassword && !loadConfig().hasLockoutPassword) { + return badRequest(res, 'Set a lockout password before enabling password protection'); + } + } + + try { + res.json(saveConfig(patch)); + } catch (e) { + console.error('saveConfig failed:', e); + res.status(500).json({ error: 'Failed to save configuration' }); + } +}); + +/* ---------------------------------------------------------------------- */ +/* Addons: install/reconfigure via the allow-listed root helper */ +/* ---------------------------------------------------------------------- */ + +app.get('/api/actions', (req, res) => { + const list = Object.entries(ACTIONS).map(([name, a]) => ({ name, label: a.label, fields: a.fields })); + res.json(list); +}); + +app.get('/api/addons/status', (req, res) => { + const cmd = SUDO_CMD || HELPER_PATH; + const args = SUDO_CMD ? [HELPER_PATH, 'status_all'] : ['status_all']; + execFile(cmd, args, { timeout: 10_000 }, (err, stdout, stderr) => { + if (err) { + console.error('status_all failed:', stderr || err.message); + return res.status(500).json({ error: 'Could not read addon status' }); + } + try { + res.json(JSON.parse(stdout.trim())); + } catch (e) { + res.status(500).json({ error: 'Malformed status response' }); + } + }); +}); + +app.post('/api/actions/:name/run', (req, res) => { + try { + const job = startJob(req.params.name, req.body || {}); + res.json({ jobId: job.id, status: job.status, label: job.label }); + } catch (e) { + res.status(e.status || 500).json({ error: e.message }); + } +}); + +app.get('/api/actions/jobs/:jobId', (req, res) => { + const job = getJob(req.params.jobId); + if (!job) return res.status(404).json({ error: 'Unknown job' }); + res.json({ id: job.id, name: job.name, label: job.label, status: job.status, exitCode: job.exitCode, log: job.log.join('') }); +}); + +// Server-Sent Events: replays whatever's already logged, then streams +// new lines as they arrive, then a final `done` event - works whether +// the client connects before the job starts producing output or +// reconnects partway through (e.g. after a page reload). +app.get('/api/actions/jobs/:jobId/stream', (req, res) => { + const job = getJob(req.params.jobId); + if (!job) return res.status(404).end(); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + + if (job.log.length) { + res.write(`event: log\ndata: ${JSON.stringify(job.log.join(''))}\n\n`); + } + if (job.status !== 'running') { + res.write(`event: done\ndata: ${JSON.stringify({ status: job.status, exitCode: job.exitCode })}\n\n`); + return res.end(); + } + + const listener = (text) => { + if (text === null) { + res.write(`event: done\ndata: ${JSON.stringify({ status: job.status, exitCode: job.exitCode })}\n\n`); + res.end(); + } else { + res.write(`event: log\ndata: ${JSON.stringify(text)}\n\n`); + } + }; + job.listeners.add(listener); + req.on('close', () => job.listeners.delete(listener)); +}); + +const PORT = process.env.PORT || 8090; +const BIND_ADDR = process.env.BIND_ADDR || '0.0.0.0'; + +if (require.main === module) { + app.listen(PORT, BIND_ADDR, () => { + console.log(`Kiosk Web UI listening on ${BIND_ADDR}:${PORT}`); + }); +} + +module.exports = app; diff --git a/webui/test/api.test.js b/webui/test/api.test.js new file mode 100644 index 0000000..83435ef --- /dev/null +++ b/webui/test/api.test.js @@ -0,0 +1,170 @@ +'use strict'; + +// webui/test/api.test.js - integration test: starts the real server.js +// app on a random port against a scratch config.json and hits GET/PUT +// /api/config with real HTTP requests (Node's built-in fetch). Run with: +// node test/api.test.js + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const assert = require('assert'); + +const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webui-api-test-')); +process.env.CONFIG_PATH = path.join(scratchDir, 'config.json'); + +const app = require('../server'); + +let failures = 0; +async function check(label, fn) { + try { + await fn(); + console.log(`PASS: ${label}`); + } catch (e) { + failures++; + console.log(`FAIL: ${label} - ${e.message}`); + } +} + +async function main() { + const server = app.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', resolve)); + const port = server.address().port; + const base = `http://127.0.0.1:${port}`; + + await check('GET /api/config returns defaults on a fresh install', async () => { + const res = await fetch(`${base}/api/config`); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.deepStrictEqual(body.tabs, []); + assert.strictEqual(body.swipeMode, 'dual'); + }); + + await check('PUT /api/config saves and round-trips display settings', async () => { + const res = await fetch(`${base}/api/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ swipeMode: 'standard', allowNavigation: 'restricted', enableNavButton: false }), + }); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(body.swipeMode, 'standard'); + assert.strictEqual(body.allowNavigation, 'restricted'); + assert.strictEqual(body.enableNavButton, false); + }); + + await check('PUT rejects an invalid allowNavigation value', async () => { + const res = await fetch(`${base}/api/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ allowNavigation: 'wide-open' }), + }); + assert.strictEqual(res.status, 400); + }); + + await check('PUT rejects a duration out of range', async () => { + const res = await fetch(`${base}/api/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tabs: [{ url: 'example.com', duration: 999999 }] }), + }); + assert.strictEqual(res.status, 400); + }); + + await check('PUT normalizes bare hostnames/IPs the same way sites_parse_url does', async () => { + const res = await fetch(`${base}/api/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tabs: [ + { url: 'example.com', duration: 30, name: 'bare host' }, + { url: '192.168.1.50', duration: 30, name: 'bare ip' }, + { url: 'https://already.example.com', duration: 30, name: 'already a url' }, + ], + }), + }); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(body.tabs[0].url, 'https://example.com'); + assert.strictEqual(body.tabs[1].url, 'http://192.168.1.50'); + assert.strictEqual(body.tabs[2].url, 'https://already.example.com'); + }); + + await check('PUT rejects homeTabIndex out of range', async () => { + const res = await fetch(`${base}/api/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ homeTabIndex: 99 }), + }); + assert.strictEqual(res.status, 400); + }); + + await check('PUT accepts a valid homeTabIndex and converts inactivity minutes to stored seconds', async () => { + const res = await fetch(`${base}/api/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ homeTabIndex: 1, inactivityTimeoutMinutes: 5 }), + }); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(body.homeTabIndex, 1); + assert.strictEqual(body.inactivityTimeout, 300); + + const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8')); + assert.strictEqual(onDisk.inactivityTimeout, 300); + }); + + await check('PUT rejects enabling password protection with no password set', async () => { + const res = await fetch(`${base}/api/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enablePasswordProtection: true }), + }); + assert.strictEqual(res.status, 400); + }); + + await check('PUT enables password protection when a new password is supplied, and never echoes it back', async () => { + const res = await fetch(`${base}/api/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enablePasswordProtection: true, newLockoutPassword: 'hunter2', lockoutTimeoutMinutes: 15 }), + }); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(body.enablePasswordProtection, true); + assert.strictEqual(body.hasLockoutPassword, true); + assert.strictEqual(body.lockoutPassword, undefined); + assert.strictEqual(JSON.stringify(body).includes('hunter2'), false); + }); + + await check('PUT rejects a malformed lockoutAtTime', async () => { + const res = await fetch(`${base}/api/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lockoutAtTime: '25:99' }), + }); + assert.strictEqual(res.status, 400); + }); + + await check('PUT re-enabling protection without a new password succeeds once one is already set', async () => { + const res = await fetch(`${base}/api/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enablePasswordProtection: true, lockoutTimeoutMinutes: 20 }), + }); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(body.lockoutTimeout, 20); + }); + + server.close(); + fs.rmSync(scratchDir, { recursive: true, force: true }); + + if (failures > 0) { + console.log(`${failures} FAILURE(S)`); + process.exit(1); + } + console.log('ALL DONE'); +} + +main(); diff --git a/webui/test/config.test.js b/webui/test/config.test.js new file mode 100644 index 0000000..46bd620 --- /dev/null +++ b/webui/test/config.test.js @@ -0,0 +1,137 @@ +'use strict'; + +// webui/test/config.test.js - unit tests for lib/config.js against a +// scratch config.json. Run with: node test/config.test.js +// +// Mirrors this project's bash test convention (PASS/FAIL lines, ALL DONE +// at the end) rather than pulling in a test framework dependency. + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const assert = require('assert'); + +const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webui-config-test-')); +process.env.CONFIG_PATH = path.join(scratchDir, 'config.json'); + +const { loadConfig, saveConfig } = require('../lib/config'); + +let failures = 0; +function check(label, fn) { + try { + fn(); + console.log(`PASS: ${label}`); + } catch (e) { + failures++; + console.log(`FAIL: ${label} - ${e.message}`); + } +} + +check('loadConfig on a missing file returns documented defaults', () => { + const cfg = loadConfig(); + assert.deepStrictEqual(cfg.tabs, []); + assert.strictEqual(cfg.swipeMode, 'dual'); + assert.strictEqual(cfg.allowNavigation, 'same-origin'); + assert.strictEqual(cfg.homeTabIndex, -1); + assert.strictEqual(cfg.inactivityTimeout, 120); + assert.strictEqual(cfg.enablePasswordProtection, false); + assert.strictEqual(cfg.hasLockoutPassword, false); + assert.strictEqual(cfg.dualSwipe, true); +}); + +check('saveConfig creates the file and round-trips scalar fields', () => { + const result = saveConfig({ swipeMode: 'standard', allowNavigation: 'open', enablePauseButton: false }); + assert.strictEqual(result.swipeMode, 'standard'); + assert.strictEqual(result.allowNavigation, 'open'); + assert.strictEqual(result.enablePauseButton, false); + assert.strictEqual(result.dualSwipe, false); + + const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8')); + assert.strictEqual(onDisk.swipeMode, 'standard'); + assert.strictEqual(onDisk.autoswitch, true); + assert.strictEqual(onDisk.enableTouch, true); +}); + +check('saveConfig merge preserves fields this app never tracks (the previously-fixed clobber bug)', () => { + // Simulate a file with Authelia + quiet-hours fields already set, the + // way the terminal addon/menus would have written them - config.js + // must never know these exist and must never delete them. + const existing = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8')); + existing.autheliaURL = 'https://auth.example.com'; + existing.autheliaUsername = 'kiosk'; + existing.autheliaEncryptedPassword = 'deadbeef'; + existing.lockoutActiveStart = '22:00'; + existing.lockoutActiveEnd = '06:00'; + fs.writeFileSync(process.env.CONFIG_PATH, JSON.stringify(existing)); + + saveConfig({ enableNavButton: false }); + + const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8')); + assert.strictEqual(onDisk.autheliaURL, 'https://auth.example.com'); + assert.strictEqual(onDisk.autheliaUsername, 'kiosk'); + assert.strictEqual(onDisk.autheliaEncryptedPassword, 'deadbeef'); + assert.strictEqual(onDisk.lockoutActiveStart, '22:00'); + assert.strictEqual(onDisk.lockoutActiveEnd, '06:00'); + assert.strictEqual(onDisk.enableNavButton, false); +}); + +check('saveConfig tabs: new password gets hashed, never stored/returned as plaintext', () => { + const result = saveConfig({ + tabs: [{ url: 'https://a.example.com', duration: 30, name: 'A', username: 'bob', password: 'hunter2' }], + }); + assert.strictEqual(result.tabs[0].hasPassword, true); + assert.strictEqual(result.tabs[0].username, 'bob'); + assert.strictEqual(result.tabs[0].password, undefined); + + const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8')); + assert.strictEqual(onDisk.tabs[0].password, 'hunter2'); // stored plaintext by design, matches lib/config.sh's own PASSES/USERS handling for Basic Auth (not the lockout password) +}); + +check('saveConfig tabs: omitting password on an existing tab keeps the stored one (positional identity)', () => { + saveConfig({ + tabs: [{ url: 'https://a.example.com', duration: 45, name: 'A renamed', username: 'bob' }], + }); + const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8')); + assert.strictEqual(onDisk.tabs[0].password, 'hunter2'); + assert.strictEqual(onDisk.tabs[0].duration, 45); + assert.strictEqual(onDisk.tabs[0].name, 'A renamed'); +}); + +check('saveConfig lockout password is SHA-256 hashed, matching lockout.sh/main.js', () => { + const crypto = require('crypto'); + saveConfig({ enablePasswordProtection: true, newLockoutPassword: 'correcthorse' }); + const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8')); + const expected = crypto.createHash('sha256').update('correcthorse', 'utf8').digest('hex'); + assert.strictEqual(onDisk.lockoutPassword, expected); + + const result = loadConfig(); + assert.strictEqual(result.hasLockoutPassword, true); + assert.strictEqual(result.lockoutPassword, undefined); +}); + +check('saveConfig disabling password protection clears the whole lockout state (matches action_disable_protection)', () => { + saveConfig({ enablePasswordProtection: true, newLockoutPassword: 'x', lockoutTimeout: 30 }); + const before = loadConfig(); + assert.strictEqual(before.hasLockoutPassword, true); + + saveConfig({ enablePasswordProtection: false }); + const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8')); + assert.strictEqual(onDisk.lockoutPassword, ''); + assert.strictEqual(onDisk.lockoutTimeout, 0); + assert.strictEqual(onDisk.lockoutAtTime, ''); + assert.strictEqual(onDisk.requirePasswordOnBoot, false); +}); + +check('saveConfig with invalid JSON already on disk falls back to {} rather than crashing', () => { + fs.writeFileSync(process.env.CONFIG_PATH, '{not valid json'); + const result = saveConfig({ swipeMode: 'dual' }); + assert.strictEqual(result.swipeMode, 'dual'); +}); + +fs.rmSync(scratchDir, { recursive: true, force: true }); + +if (failures > 0) { + console.log(`${failures} FAILURE(S)`); + process.exit(1); +} +console.log('ALL DONE'); diff --git a/webui/test/fixtures/fake-helper-with-status.sh b/webui/test/fixtures/fake-helper-with-status.sh new file mode 100755 index 0000000..3a49f29 --- /dev/null +++ b/webui/test/fixtures/fake-helper-with-status.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Variant of fake-helper.sh that also implements status_all with real, +# minimally stateful JSON (tracked via marker files alongside itself), +# for browser/manual smoke testing of the Addons page's pills/buttons +# actually flipping after a real install - not just that a job reports +# success. jobs.test.js intentionally uses the plainer fake-helper.sh +# instead, to exercise the malformed-status-response error path. +STATE_DIR="$(dirname "$0")/.fake-state" +mkdir -p "$STATE_DIR" + +if [[ "$1" == "status_all" ]]; then + state() { [[ -f "$STATE_DIR/$1" ]] && echo true || echo false; } + echo "{\"cups\":$(state cups),\"lms\":$(state lms),\"squeezelite\":$(state squeezelite),\"asterisk_intercom\":$(state asterisk_intercom)}" + exit 0 +fi + +echo "fake-helper: action=$1" +stdin_content=$(cat) +echo "fake-helper: stdin-bytes=${#stdin_content}" +sleep 0.3 + +case "$1" in + action_install_cups | action_reconfigure_cups) touch "$STATE_DIR/cups" ;; + action_install_lms) touch "$STATE_DIR/lms" ;; + action_install_squeezelite) touch "$STATE_DIR/squeezelite" ;; + action_configure_asterisk_intercom) touch "$STATE_DIR/asterisk_intercom" ;; +esac + +echo "fake-helper: done" +exit "${FAKE_HELPER_EXIT_CODE:-0}" diff --git a/webui/test/fixtures/fake-helper.sh b/webui/test/fixtures/fake-helper.sh new file mode 100755 index 0000000..ac8392f --- /dev/null +++ b/webui/test/fixtures/fake-helper.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# webui/test/fixtures/fake-helper.sh - stands in for the real, root-owned +# kiosk-webui-helper (menus/addon_webui.sh) in webui/test/jobs.test.js, +# so the job/SSE system can be tested without real root or a real addon +# install. Echoes what it received, sleeps briefly (long enough for the +# "another job is already running" test to reliably observe it), then +# exits with a controllable code. +echo "fake-helper: action=$1" +stdin_content=$(cat) +echo "fake-helper: stdin-bytes=${#stdin_content}" +sleep 0.2 +echo "fake-helper: done" +exit "${FAKE_HELPER_EXIT_CODE:-0}" diff --git a/webui/test/jobs.test.js b/webui/test/jobs.test.js new file mode 100644 index 0000000..c5ba9ed --- /dev/null +++ b/webui/test/jobs.test.js @@ -0,0 +1,190 @@ +'use strict'; + +// webui/test/jobs.test.js - integration test for the addon-install job +// system (/api/actions/*, /api/addons/status) against a fake helper +// script (test/fixtures/fake-helper.sh) instead of the real, root-owned +// kiosk-webui-helper - no real root, apt, or system mutation involved, +// matching this project's rule of never touching real system state in +// tests. Run with: node test/jobs.test.js + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const assert = require('assert'); + +const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webui-jobs-test-')); +process.env.CONFIG_PATH = path.join(scratchDir, 'config.json'); +process.env.HELPER_PATH = path.join(__dirname, 'fixtures', 'fake-helper.sh'); +process.env.SUDO_CMD = ''; // run the fake helper directly, no real sudo + +const app = require('../server'); + +let failures = 0; +async function check(label, fn) { + try { + await fn(); + console.log(`PASS: ${label}`); + } catch (e) { + failures++; + console.log(`FAIL: ${label} - ${e.message}`); + } +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForDone(base, jobId, timeoutMs = 3000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const res = await fetch(`${base}/api/actions/jobs/${jobId}`); + const body = await res.json(); + if (body.status !== 'running') return body; + await sleep(20); + } + throw new Error('timed out waiting for job to finish'); +} + +async function main() { + const server = app.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', resolve)); + const port = server.address().port; + const base = `http://127.0.0.1:${port}`; + + await check('GET /api/actions lists the allow-listed actions with their fields', async () => { + const res = await fetch(`${base}/api/actions`); + assert.strictEqual(res.status, 200); + const body = await res.json(); + const names = body.map((a) => a.name); + assert.ok(names.includes('install_cups')); + assert.ok(names.includes('configure_asterisk_intercom')); + const asterisk = body.find((a) => a.name === 'configure_asterisk_intercom'); + assert.ok(asterisk.fields.includes('serverIp')); + }); + + await check('GET /api/addons/status returns the fake helper\'s status_all JSON', async () => { + // fake-helper.sh doesn't implement status_all specially - it just + // echoes/exits 0 with non-JSON text, so this exercises the + // malformed-response error path rather than a real status shape. + const res = await fetch(`${base}/api/addons/status`); + assert.strictEqual(res.status, 500); + }); + + let jobId; + await check('POST /api/actions/install_cups/run starts a job and returns its id', async () => { + const res = await fetch(`${base}/api/actions/install_cups/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.ok(body.jobId); + assert.strictEqual(body.status, 'running'); + jobId = body.jobId; + }); + + await check('a second action while one is running is rejected with 409', async () => { + const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + assert.strictEqual(res.status, 409); + }); + + await check('the job completes successfully and the log shows what the helper received', async () => { + const body = await waitForDone(base, jobId); + assert.strictEqual(body.status, 'success'); + assert.strictEqual(body.exitCode, 0); + assert.ok(body.log.includes('fake-helper: action=action_install_cups'), body.log); + assert.ok(body.log.includes('fake-helper: stdin-bytes='), body.log); + assert.ok(body.log.includes('fake-helper: done'), body.log); + }); + + await check('after completion, a new action is accepted again (not stuck busy)', async () => { + const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + assert.strictEqual(res.status, 200); + const body = await res.json(); + await waitForDone(base, body.jobId); + }); + + await check('unknown action name is rejected with 400, no job created', async () => { + const res = await fetch(`${base}/api/actions/definitely_not_real/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + assert.strictEqual(res.status, 400); + }); + + await check('missing required field is rejected with 400 before spawning anything', async () => { + const res = await fetch(`${base}/api/actions/configure_asterisk_intercom/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ alreadyInstalled: false, extension: '201', password: 'x' }), + }); + assert.strictEqual(res.status, 400); + const body = await res.json(); + assert.ok(/serverIp/.test(body.error), body.error); + }); + + await check('a failing helper is reflected as status failed with the real exit code', async () => { + process.env.FAKE_HELPER_EXIT_CODE = '1'; + // jobs.js reads process.env.HELPER_PATH/SUDO_CMD once at module + // load, but FAKE_HELPER_EXIT_CODE is read fresh by the spawned + // shell script every time, so no re-require needed here. + const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + const { jobId: failId } = await res.json(); + const body = await waitForDone(base, failId); + assert.strictEqual(body.status, 'failed'); + assert.strictEqual(body.exitCode, 1); + delete process.env.FAKE_HELPER_EXIT_CODE; + }); + + await check('GET /api/actions/jobs/:id/stream (SSE) replays the log and sends a final done event', async () => { + const res = await fetch(`${base}/api/actions/install_lms/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ alreadyInstalled: false }), + }); + const { jobId: streamJobId } = await res.json(); + + const streamRes = await fetch(`${base}/api/actions/jobs/${streamJobId}/stream`); + assert.strictEqual(streamRes.status, 200); + assert.strictEqual(streamRes.headers.get('content-type'), 'text/event-stream'); + + const reader = streamRes.body.getReader(); + const decoder = new TextDecoder(); + let raw = ''; + const deadline = Date.now() + 3000; + while (!raw.includes('event: done') && Date.now() < deadline) { + const { value, done } = await reader.read(); + if (done) break; + raw += decoder.decode(value, { stream: true }); + } + assert.ok(raw.includes('event: log'), raw); + assert.ok(raw.includes('fake-helper: action=action_install_lms'), raw); + assert.ok(raw.includes('event: done'), raw); + assert.ok(/data: \{"status":"success","exitCode":0\}/.test(raw), raw); + }); + + server.close(); + fs.rmSync(scratchDir, { recursive: true, force: true }); + + if (failures > 0) { + console.log(`${failures} FAILURE(S)`); + process.exit(1); + } + console.log('ALL DONE'); +} + +main();