Files
ubuntu-based-kiosk/webui/lib/config.js
T
Claude b6ed4aad9c 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
2026-08-19 16:01:20 +00:00

162 lines
6.3 KiB
JavaScript

'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 };