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,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