Add Web UI addon: browser-based config editor (v2.16.0)
New Addons -> Web UI: a small Node/Express app (webui/) installed 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. Runs with no sudo at all, since config.json is already owned by $KIOSK_USER. 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 config.json directly in JS, so this isn't a new pattern), so it can never silently clobber fields it doesn't track - the same bug 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 - Authelia integration is explicitly out of scope for this repo. 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, since a network-facing process shouldn't be handed sudo-level system mutation without a lot more thought than this pass gives it. 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 this repo's "Modular Management" notes have mentioned for a while - a central multi-kiosk fleet dashboard is an intentional follow-up. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
This commit is contained in:
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -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.
|
||||
|
||||
+10
-3
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <<EOF
|
||||
PORT=${port}
|
||||
BIND_ADDR=0.0.0.0
|
||||
CONFIG_PATH=${CONFIG_PATH}
|
||||
EOF
|
||||
}
|
||||
|
||||
webui_write_unit_file() {
|
||||
sudo tee "$SYSTEMD_DIR/kiosk-webui.service" > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=Kiosk Web UI
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${KIOSK_USER}
|
||||
WorkingDirectory=${WEBUI_DIR}
|
||||
EnvironmentFile=${WEBUI_ENV_DIR}/webui.env
|
||||
ExecStart=/usr/bin/node ${WEBUI_DIR}/server.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Actions
|
||||
################################################################################
|
||||
|
||||
action_webui_install() {
|
||||
echo
|
||||
echo "This installs a small web app for editing Sites & Page Timing,"
|
||||
echo "Display & Interaction, and Password Protection & Lockout from a"
|
||||
echo "browser. It has no login of its own - put it behind your own"
|
||||
echo "reverse proxy (e.g. Caddy + Authelia) if it needs to be reachable"
|
||||
echo "beyond a trusted LAN."
|
||||
echo
|
||||
ask_yes_no "Install the Web UI?" "n" || { echo "Cancelled"; return; }
|
||||
|
||||
local port
|
||||
port=$(ask_integer "Port to listen on" "8090" 1024 65535)
|
||||
|
||||
echo "Installing app files and npm dependencies..."
|
||||
if ! webui_install_app_files; then
|
||||
log_error "Web UI install failed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
webui_write_env_file "$port"
|
||||
webui_write_unit_file
|
||||
|
||||
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_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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+36
-2
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
Generated
+828
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "kiosk-webui",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"dependencies": {
|
||||
"express": "^4.19.0"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,139 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Kiosk Web UI</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Kiosk Web UI</h1>
|
||||
<p class="subtitle">Sites, display behavior, and lockout - saved directly to this kiosk's config.json.</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section id="msg" class="banner" hidden></section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Sites & Page Timing</h2>
|
||||
<div id="sites-list"></div>
|
||||
<div class="button-row">
|
||||
<button type="button" id="add-site" class="secondary">Add a page</button>
|
||||
<button type="button" id="save-sites">Save Sites & Page Timing</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Display & Interaction</h2>
|
||||
<form id="display-form">
|
||||
<label>
|
||||
Touch gesture mode
|
||||
<select name="swipeMode">
|
||||
<option value="dual">Dual-direction (recommended for touchscreens)</option>
|
||||
<option value="standard">Standard</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Link navigation security
|
||||
<select name="allowNavigation">
|
||||
<option value="restricted">Restricted - only the loaded URL</option>
|
||||
<option value="same-origin">Same-origin - links within the same domain (recommended)</option>
|
||||
<option value="open">Open - any link</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="checkbox"><input type="checkbox" name="enablePauseButton"> Pause button</label>
|
||||
<label class="checkbox"><input type="checkbox" name="enableKeyboardButton"> On-screen keyboard button</label>
|
||||
<label class="checkbox"><input type="checkbox" name="enableNavButton"> Navigation/help button</label>
|
||||
|
||||
<fieldset>
|
||||
<legend>Home page</legend>
|
||||
<label>
|
||||
Home page
|
||||
<select name="homeTabIndex" id="home-tab-select">
|
||||
<option value="-1">Disabled</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Inactivity timeout (minutes)
|
||||
<input type="number" name="inactivityTimeoutMinutes" min="1" max="240" step="1">
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit">Save Display & Interaction</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Password Protection & Lockout</h2>
|
||||
<form id="lockout-form">
|
||||
<label class="checkbox"><input type="checkbox" name="enablePasswordProtection" id="lockout-enabled"> Enable password protection</label>
|
||||
|
||||
<div id="lockout-fields">
|
||||
<label>
|
||||
<span id="password-label">Set lockout password</span>
|
||||
<input type="password" name="newLockoutPassword" autocomplete="new-password">
|
||||
</label>
|
||||
<label>
|
||||
Confirm password
|
||||
<input type="password" id="lockout-password-confirm" autocomplete="new-password">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Inactivity lockout timeout (minutes, 0 = boot/wake only)
|
||||
<input type="number" name="lockoutTimeoutMinutes" min="0" max="1440" step="1">
|
||||
</label>
|
||||
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" id="daily-lock-enabled"> Lock at a specific time daily
|
||||
</label>
|
||||
<label>
|
||||
Daily lock time
|
||||
<input type="time" name="lockoutAtTime" id="lockout-at-time" disabled>
|
||||
</label>
|
||||
|
||||
<label class="checkbox"><input type="checkbox" name="requirePasswordOnBoot"> Require password on system boot</label>
|
||||
</div>
|
||||
|
||||
<button type="submit">Save Password Protection & Lockout</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<template id="site-row-template">
|
||||
<div class="site-row card-inset">
|
||||
<div class="site-row-grid">
|
||||
<label>
|
||||
URL
|
||||
<input type="text" class="site-url" placeholder="example.com or https://example.com">
|
||||
</label>
|
||||
<label>
|
||||
Name (optional)
|
||||
<input type="text" class="site-name" placeholder="Shown instead of the URL">
|
||||
</label>
|
||||
<label>
|
||||
Duration (seconds; -1=hidden, 0=manual, >0=auto-rotate)
|
||||
<input type="number" class="site-duration" min="-1" max="86400" step="1" value="180">
|
||||
</label>
|
||||
</div>
|
||||
<details class="site-auth">
|
||||
<summary>HTTP Basic Auth <span class="auth-state"></span></summary>
|
||||
<label class="checkbox"><input type="checkbox" class="site-auth-enable"> Requires a username/password</label>
|
||||
<label>
|
||||
Username
|
||||
<input type="text" class="site-auth-username">
|
||||
</label>
|
||||
<label>
|
||||
New password <span class="hint">(leave blank to keep the current one)</span>
|
||||
<input type="password" class="site-auth-password" autocomplete="new-password">
|
||||
</label>
|
||||
</details>
|
||||
<button type="button" class="remove-site danger">Remove page</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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); }
|
||||
+142
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user