Web UI now installs by default during first-time provisioning (fixed port 8090, no prompt) instead of being opt-in, and can install/ reconfigure CUPS Printing, LMS Server, Squeezelite Player, and Asterisk Intercom, and check for updates - the addons and Update action asked for by name. Privilege model: the web service itself still runs as $KIOSK_USER with zero ambient sudo. A new narrow, allow-listed root helper (menus/addon_webui.sh's webui_write_helper_script) is the only way it ever gains privilege, reachable only via a single-path passwordless sudo rule generated and validated with `visudo -c -f` before being installed, and it re-checks its own fixed action allow-list before dispatching anything. Each allow-listed action is the exact same interactive action_* function the terminal menu already uses, driven by piping the right answers on stdin - the same technique this project's own bash tests already use, so no prompt/mutation refactor of any addon file was needed. webui/lib/actions.js's stdin sequences were cross-validated against the real bash functions (not just read), which caught two real bugs (Squeezelite and Asterisk Intercom both silently lost their "decline reconfigure" path). Long-running installs stream live output via Server-Sent Events (webui/lib/jobs.js), one action at a time. Full visual redesign: a sidebar shell (Sites/Display/Lockout/Addons/ Update) replacing the single scrolling page, light+dark themes via prefers-color-scheme, no external font/CDN dependency. Actually driving the redesigned UI in a headless browser (not just reading the code) caught a real bug: refreshing an addon's pill/button after a successful install used to rebuild the whole card, racing (and usually losing to) the success status/log that job had just written. Fixed to update pill/buttons in place. Uninstall-via-web is deliberately still not offered, for any addon. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
223 lines
9.0 KiB
JavaScript
223 lines
9.0 KiB
JavaScript
'use strict';
|
|
|
|
// webui/server.js - Kiosk Web UI: browser-based config editor for Sites &
|
|
// Page Timing, Display & Interaction, and Password Protection & Lockout -
|
|
// the three Core Settings menus that are pure config.json read/write with
|
|
// no privileged system mutation involved (see menus/addon_webui.sh's
|
|
// header for why the rest of Core Settings/Addons/Advanced aren't here).
|
|
//
|
|
// No login of its own by design: Authelia runs elsewhere, and the admin
|
|
// site goes behind the user's own Caddy reverse proxy with Authelia
|
|
// forward-auth in front of it, the same way every other self-hosted app
|
|
// they run is protected. This process only binds where it's told to
|
|
// (BIND_ADDR/PORT below) and trusts whatever's in front of it.
|
|
//
|
|
// Runs as $KIOSK_USER (see the systemd unit menus/addon_webui.sh
|
|
// installs) - the same user Electron runs as, and the owner of
|
|
// config.json - so it never needs sudo.
|
|
|
|
const path = require('path');
|
|
const { execFile } = require('child_process');
|
|
const express = require('express');
|
|
const { loadConfig, saveConfig } = require('./lib/config');
|
|
const { ACTIONS } = require('./lib/actions');
|
|
const { startJob, getJob } = require('./lib/jobs');
|
|
|
|
const HELPER_PATH = process.env.HELPER_PATH || '/usr/local/bin/kiosk-webui-helper';
|
|
const SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'sudo';
|
|
|
|
const app = express();
|
|
app.use(express.json({ limit: '256kb' }));
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
const SWIPE_MODES = ['dual', 'standard'];
|
|
const NAV_MODES = ['restricted', 'same-origin', 'open'];
|
|
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
|
|
// Same normalization rule as menus/sites.sh's sites_parse_url(): bare
|
|
// host -> https://, bare IPv4 -> http://, else passed through as-is.
|
|
function parseUrl(raw) {
|
|
if (/^https?:\/\//.test(raw)) return raw;
|
|
if (/^\d+\.\d+\.\d+\.\d+/.test(raw)) return `http://${raw}`;
|
|
return `https://${raw}`;
|
|
}
|
|
|
|
function badRequest(res, message) {
|
|
res.status(400).json({ error: message });
|
|
}
|
|
|
|
app.get('/api/config', (req, res) => {
|
|
res.json(loadConfig());
|
|
});
|
|
|
|
app.put('/api/config', (req, res) => {
|
|
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
const patch = {};
|
|
|
|
if (body.swipeMode !== undefined) {
|
|
if (!SWIPE_MODES.includes(body.swipeMode)) return badRequest(res, 'swipeMode must be "dual" or "standard"');
|
|
patch.swipeMode = body.swipeMode;
|
|
}
|
|
if (body.allowNavigation !== undefined) {
|
|
if (!NAV_MODES.includes(body.allowNavigation)) {
|
|
return badRequest(res, 'allowNavigation must be "restricted", "same-origin", or "open"');
|
|
}
|
|
patch.allowNavigation = body.allowNavigation;
|
|
}
|
|
if (body.enablePauseButton !== undefined) patch.enablePauseButton = !!body.enablePauseButton;
|
|
if (body.enableKeyboardButton !== undefined) patch.enableKeyboardButton = !!body.enableKeyboardButton;
|
|
if (body.enableNavButton !== undefined) patch.enableNavButton = !!body.enableNavButton;
|
|
|
|
let tabCount;
|
|
if (body.tabs !== undefined) {
|
|
if (!Array.isArray(body.tabs)) return badRequest(res, 'tabs must be an array');
|
|
for (const t of body.tabs) {
|
|
if (!t || typeof t.url !== 'string' || t.url.trim() === '') return badRequest(res, 'Every site needs a URL');
|
|
const dur = Number(t.duration);
|
|
if (!Number.isInteger(dur) || dur < -1 || dur > 86400) {
|
|
return badRequest(res, 'Duration must be a whole number between -1 and 86400');
|
|
}
|
|
}
|
|
patch.tabs = body.tabs.map((t) => ({ ...t, url: parseUrl(t.url.trim()) }));
|
|
tabCount = patch.tabs.length;
|
|
}
|
|
|
|
if (body.homeTabIndex !== undefined) {
|
|
const idx = Number(body.homeTabIndex);
|
|
const count = tabCount !== undefined ? tabCount : loadConfig().tabs.length;
|
|
if (!Number.isInteger(idx) || idx < -1 || idx >= count) return badRequest(res, 'homeTabIndex is out of range');
|
|
patch.homeTabIndex = idx;
|
|
}
|
|
|
|
if (body.inactivityTimeoutMinutes !== undefined) {
|
|
const min = Number(body.inactivityTimeoutMinutes);
|
|
if (!Number.isInteger(min) || min < 1 || min > 240) return badRequest(res, 'Inactivity timeout must be 1-240 minutes');
|
|
patch.inactivityTimeout = min * 60;
|
|
}
|
|
|
|
if (body.enablePasswordProtection !== undefined) patch.enablePasswordProtection = !!body.enablePasswordProtection;
|
|
|
|
if (body.lockoutTimeoutMinutes !== undefined) {
|
|
const min = Number(body.lockoutTimeoutMinutes);
|
|
if (!Number.isInteger(min) || min < 0 || min > 1440) return badRequest(res, 'Lockout timeout must be 0-1440 minutes');
|
|
patch.lockoutTimeout = min;
|
|
}
|
|
|
|
if (body.lockoutAtTime !== undefined) {
|
|
if (body.lockoutAtTime !== '' && !TIME_RE.test(body.lockoutAtTime)) {
|
|
return badRequest(res, 'lockoutAtTime must be HH:MM (24-hour) or empty');
|
|
}
|
|
patch.lockoutAtTime = body.lockoutAtTime;
|
|
}
|
|
|
|
if (body.requirePasswordOnBoot !== undefined) patch.requirePasswordOnBoot = !!body.requirePasswordOnBoot;
|
|
|
|
if (body.newLockoutPassword !== undefined) {
|
|
if (typeof body.newLockoutPassword !== 'string' || body.newLockoutPassword.length === 0) {
|
|
return badRequest(res, 'Password cannot be empty');
|
|
}
|
|
patch.newLockoutPassword = body.newLockoutPassword;
|
|
}
|
|
|
|
// Mirrors action_enable_protection() always requiring a password up
|
|
// front - lockout.sh has no path that enables protection without one.
|
|
if (patch.enablePasswordProtection === true) {
|
|
const hasNewPassword = typeof patch.newLockoutPassword === 'string' && patch.newLockoutPassword.length > 0;
|
|
if (!hasNewPassword && !loadConfig().hasLockoutPassword) {
|
|
return badRequest(res, 'Set a lockout password before enabling password protection');
|
|
}
|
|
}
|
|
|
|
try {
|
|
res.json(saveConfig(patch));
|
|
} catch (e) {
|
|
console.error('saveConfig failed:', e);
|
|
res.status(500).json({ error: 'Failed to save configuration' });
|
|
}
|
|
});
|
|
|
|
/* ---------------------------------------------------------------------- */
|
|
/* Addons: install/reconfigure via the allow-listed root helper */
|
|
/* ---------------------------------------------------------------------- */
|
|
|
|
app.get('/api/actions', (req, res) => {
|
|
const list = Object.entries(ACTIONS).map(([name, a]) => ({ name, label: a.label, fields: a.fields }));
|
|
res.json(list);
|
|
});
|
|
|
|
app.get('/api/addons/status', (req, res) => {
|
|
const cmd = SUDO_CMD || HELPER_PATH;
|
|
const args = SUDO_CMD ? [HELPER_PATH, 'status_all'] : ['status_all'];
|
|
execFile(cmd, args, { timeout: 10_000 }, (err, stdout, stderr) => {
|
|
if (err) {
|
|
console.error('status_all failed:', stderr || err.message);
|
|
return res.status(500).json({ error: 'Could not read addon status' });
|
|
}
|
|
try {
|
|
res.json(JSON.parse(stdout.trim()));
|
|
} catch (e) {
|
|
res.status(500).json({ error: 'Malformed status response' });
|
|
}
|
|
});
|
|
});
|
|
|
|
app.post('/api/actions/:name/run', (req, res) => {
|
|
try {
|
|
const job = startJob(req.params.name, req.body || {});
|
|
res.json({ jobId: job.id, status: job.status, label: job.label });
|
|
} catch (e) {
|
|
res.status(e.status || 500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/actions/jobs/:jobId', (req, res) => {
|
|
const job = getJob(req.params.jobId);
|
|
if (!job) return res.status(404).json({ error: 'Unknown job' });
|
|
res.json({ id: job.id, name: job.name, label: job.label, status: job.status, exitCode: job.exitCode, log: job.log.join('') });
|
|
});
|
|
|
|
// Server-Sent Events: replays whatever's already logged, then streams
|
|
// new lines as they arrive, then a final `done` event - works whether
|
|
// the client connects before the job starts producing output or
|
|
// reconnects partway through (e.g. after a page reload).
|
|
app.get('/api/actions/jobs/:jobId/stream', (req, res) => {
|
|
const job = getJob(req.params.jobId);
|
|
if (!job) return res.status(404).end();
|
|
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
Connection: 'keep-alive',
|
|
});
|
|
|
|
if (job.log.length) {
|
|
res.write(`event: log\ndata: ${JSON.stringify(job.log.join(''))}\n\n`);
|
|
}
|
|
if (job.status !== 'running') {
|
|
res.write(`event: done\ndata: ${JSON.stringify({ status: job.status, exitCode: job.exitCode })}\n\n`);
|
|
return res.end();
|
|
}
|
|
|
|
const listener = (text) => {
|
|
if (text === null) {
|
|
res.write(`event: done\ndata: ${JSON.stringify({ status: job.status, exitCode: job.exitCode })}\n\n`);
|
|
res.end();
|
|
} else {
|
|
res.write(`event: log\ndata: ${JSON.stringify(text)}\n\n`);
|
|
}
|
|
};
|
|
job.listeners.add(listener);
|
|
req.on('close', () => job.listeners.delete(listener));
|
|
});
|
|
|
|
const PORT = process.env.PORT || 8090;
|
|
const BIND_ADDR = process.env.BIND_ADDR || '0.0.0.0';
|
|
|
|
if (require.main === module) {
|
|
app.listen(PORT, BIND_ADDR, () => {
|
|
console.log(`Kiosk Web UI listening on ${BIND_ADDR}:${PORT}`);
|
|
});
|
|
}
|
|
|
|
module.exports = app;
|