diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/Readme.md b/Readme.md index bd6c017..f54abe2 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.16.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,13 @@ 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): a small + Node/Express app installed as a systemd service under `$KIOSK_USER`, + giving a browser-based editor for Sites & Page Timing, Display & + Interaction, and Password Protection & Lockout — 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.16.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 +1353,17 @@ full migration pass. ## Project Status & Future Plans -**Current Version:** 2.15.0 +**Current Version:** 2.16.0 -**Recent Updates (v2.15.0):** +**Recent Updates (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..b9fce29 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -31,6 +31,8 @@ : "${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}" # 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/menus/addon_webui.sh b/menus/addon_webui.sh new file mode 100644 index 0000000..950f01b --- /dev/null +++ b/menus/addon_webui.sh @@ -0,0 +1,209 @@ +#!/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 - the three Core Settings menus that +# are pure config.json read/write with no privileged system mutation +# involved. The remaining Core Settings/Addons/Advanced menus (WiFi, +# Timezone, Power/Display/Quiet Hours, Complete Uninstall, every other +# addon, everything in Advanced) stay terminal-only for now - a +# network-facing process shouldn't be handed sudo-level system mutation +# without a lot more thought than this first pass gives it. +# +# 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. +# +# webui/'s own app-level logic (config.json schema/merge, API validation) +# lives and is tested entirely under webui/ - this file only wires it up +# as a system service 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 </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 -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..001e8af 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,42 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v2.15.0 ### +### Ubuntu Based Kiosk v2.16.0 ### ################################################################################ # +# 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 +580,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="2.15.0" +SCRIPT_VERSION="2.16.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/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/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..f4488f2 --- /dev/null +++ b/webui/public/app.js @@ -0,0 +1,264 @@ +'use strict'; + +// webui/public/app.js - vanilla JS, no framework/build step. Every +// user-controlled value (site URL/name/username) is set via .value or +// .textContent, never innerHTML, so nothing typed into a site name or +// URL 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); +} + +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: omit `password` entirely - server keeps the existing one. + } else { + tab.username = ''; + tab.password = ''; + } + return tab; + }); +} + +// A "Save" per row would be simpler individually, but sites.sh's own +// save_config always rewrites the whole tabs array too - this mirrors +// that, saving all sites (and the home-page selection they feed into) +// together whenever anything in the Sites section changes. +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); + } +}); + +/* ---------------------------------------------------------------------- */ +/* Populate forms from a config snapshot */ +/* ---------------------------------------------------------------------- */ + +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(); +} + +async function init() { + try { + currentConfig = await apiGet(); + renderSites(currentConfig.tabs); + populateAll(currentConfig); + } catch (e) { + showMessage(e.message, true); + } +} + +init(); diff --git a/webui/public/index.html b/webui/public/index.html new file mode 100644 index 0000000..c6e2df9 --- /dev/null +++ b/webui/public/index.html @@ -0,0 +1,139 @@ + + + + + +Kiosk Web UI + + + +
+

Kiosk Web UI

+

Sites, display behavior, and lockout - saved directly to this kiosk's config.json.

+
+ +
+ + +
+

Sites & Page Timing

+
+
+ + +
+
+ +
+

Display & Interaction

+
+ + + + + + + + +
+ Home page + + +
+ + +
+
+ +
+

Password Protection & Lockout

+
+ + +
+ + + + + + + + + +
+ + +
+
+
+ + + + + + diff --git a/webui/public/style.css b/webui/public/style.css new file mode 100644 index 0000000..c0673ce --- /dev/null +++ b/webui/public/style.css @@ -0,0 +1,182 @@ +:root { + --bg: #0f1115; + --panel: #171a21; + --panel-inset: #1e222b; + --border: #2a2f3a; + --text: #e6e9ef; + --text-dim: #9aa3b2; + --accent: #4f8cff; + --accent-text: #ffffff; + --danger: #e5566a; + --success: #3fb87f; + --error: #e5566a; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +header { + padding: 2rem 1.5rem 1rem; + max-width: 720px; + margin: 0 auto; +} + +h1 { margin: 0 0 0.25rem; font-size: 1.5rem; } +.subtitle { margin: 0; color: var(--text-dim); font-size: 0.9rem; } + +main { + max-width: 720px; + margin: 0 auto; + padding: 0 1.5rem 3rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.25rem; +} + +.card h2 { + margin: 0 0 1rem; + font-size: 1.1rem; +} + +.card-inset { + background: var(--panel-inset); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; + margin-bottom: 0.75rem; +} + +form { display: flex; flex-direction: column; gap: 1rem; } + +label { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-size: 0.9rem; + color: var(--text-dim); +} + +label.checkbox { + flex-direction: row; + align-items: center; + gap: 0.5rem; + color: var(--text); +} + +label.checkbox input { width: 1.05rem; height: 1.05rem; } + +.hint { color: var(--text-dim); font-size: 0.8rem; font-weight: normal; } + +input[type="text"], +input[type="password"], +input[type="number"], +input[type="time"], +select { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + padding: 0.5rem 0.6rem; + font-size: 0.95rem; +} + +input:focus, select:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +fieldset { + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.75rem 1rem 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +legend { padding: 0 0.4rem; color: var(--text-dim); font-size: 0.85rem; } + +button { + border: none; + border-radius: 6px; + padding: 0.55rem 1rem; + font-size: 0.9rem; + cursor: pointer; + background: var(--accent); + color: var(--accent-text); + align-self: flex-start; +} + +button.secondary { + background: transparent; + border: 1px solid var(--border); + color: var(--text); +} + +button.danger { + background: transparent; + border: 1px solid var(--danger); + color: var(--danger); +} + +button:hover { filter: brightness(1.1); } + +.button-row { + display: flex; + gap: 0.6rem; + margin-top: 0.75rem; +} + +.site-row-grid { + display: grid; + grid-template-columns: 2fr 1fr 1fr; + gap: 0.75rem; +} + +@media (max-width: 560px) { + .site-row-grid { grid-template-columns: 1fr; } +} + +.site-auth { + margin-top: 0.75rem; + border-top: 1px solid var(--border); + padding-top: 0.75rem; +} + +.site-auth summary { + cursor: pointer; + color: var(--text-dim); + font-size: 0.85rem; +} + +.site-auth[open] summary { margin-bottom: 0.6rem; } + +.auth-state { color: var(--text-dim); } + +.remove-site { margin-top: 0.75rem; } + +.banner { + border-radius: 8px; + padding: 0.75rem 1rem; + font-size: 0.9rem; + position: sticky; + top: 0.75rem; + z-index: 10; +} + +.banner.success { background: rgba(63, 184, 127, 0.15); border: 1px solid var(--success); color: var(--success); } +.banner.error { background: rgba(229, 86, 106, 0.15); border: 1px solid var(--error); color: var(--error); } diff --git a/webui/server.js b/webui/server.js new file mode 100644 index 0000000..07da88e --- /dev/null +++ b/webui/server.js @@ -0,0 +1,142 @@ +'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 express = require('express'); +const { loadConfig, saveConfig } = require('./lib/config'); + +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' }); + } +}); + +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');