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