Files
ESP32_Serial_Swiss_Army_Knife/src/web_ui.c
T
2026-09-03 16:44:52 +02:00

1670 lines
98 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* SPDX-License-Identifier: GPL-3.0-only */
/* Offline browser UI response helpers for public and session-authenticated HTTPS routes. */
#include "web_ui.h"
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "web_assets_data.h"
#define WEB_UI_DOCUMENT_CACHE_CONTROL "no-store"
#define WEB_UI_AUTHORED_ASSET_CACHE_CONTROL "private, max-age=300"
#define WEB_UI_ASSET_CACHE_CONTROL "private, max-age=604800"
static const char s_index_html[] =
"<!doctype html>\n"
"<html lang=\"en\">\n"
"<head>\n"
"<meta charset=\"utf-8\">\n"
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n"
"<meta name=\"color-scheme\" content=\"dark\">\n"
"<title>ESP32 Serial Console</title>\n"
"<style>\n"
":root{color-scheme:dark;--bg:#090d14;--panel:#111824;--panel-2:#161f2e;"
"--line:#29364a;--text:#e8eef8;--muted:#91a0b5;--accent:#55c2ff;"
"--good:#52d68b;--warn:#ffc857;--bad:#ff6b7a;--radius:14px}\n"
"*{box-sizing:border-box}\n"
"html,body{height:100%;margin:0;overflow:hidden}\n"
"body{background:radial-gradient(circle at top left,#142033 0,var(--bg) 42rem);"
"color:var(--text);font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"
"\"Segoe UI\",sans-serif}\n"
"button{font:inherit}\n"
"[hidden]{display:none!important}\n"
".page{height:100%;height:100dvh;min-height:0;max-width:1440px;margin:auto;"
"padding:clamp(14px,2.5vw,32px);display:grid;"
"grid-template-rows:auto auto minmax(0,1fr);gap:16px}\n"
".topbar{display:flex;align-items:center;justify-content:space-between;gap:16px}\n"
".brand{display:flex;align-items:center;gap:12px;min-width:0}\n"
".topbar-actions{display:flex;align-items:center;justify-content:flex-end;gap:10px;min-width:0}\n"
".account{display:flex;align-items:center;gap:9px;min-width:0;border:1px solid var(--line);"
"border-radius:10px;padding:6px 10px;background:#101824cc}\n"
".account-icon{display:grid;place-items:center;width:28px;height:28px;flex:0 0 auto;border-radius:50%;"
"background:#17334a;color:var(--accent);font-weight:850}\n"
".account-copy{display:flex;flex-direction:column;min-width:0;line-height:1.2}\n"
".account-username{max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:750}\n"
".account-role{color:var(--muted);font-size:11px;font-weight:750;letter-spacing:.06em;text-transform:uppercase}\n"
".logo{width:42px;height:42px;border:1px solid #347ba5;border-radius:12px;"
"display:block;padding:4px;background:#10273a;object-fit:contain;"
"box-shadow:0 0 30px #1c8fc333}\n"
"h1{font-size:clamp(18px,3vw,24px);line-height:1.15;margin:0}\n"
".subtitle{color:var(--muted);margin:3px 0 0;font-size:13px}\n"
".badge{display:inline-flex;align-items:center;gap:7px;white-space:nowrap;"
"border:1px solid var(--line);border-radius:999px;padding:6px 10px;"
"background:#121a26;color:var(--muted);font-weight:700;font-size:12px}\n"
".badge:before{content:\"\";width:7px;height:7px;border-radius:50%;background:currentColor;"
"box-shadow:0 0 10px currentColor}\n"
".badge[data-tone=good]{color:var(--good);border-color:#245a42}\n"
".badge[data-tone=warn]{color:var(--warn);border-color:#675526}\n"
".badge[data-tone=bad]{color:var(--bad);border-color:#64313b}\n"
".dashboard{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(280px,.65fr);gap:16px}\n"
".panel{background:linear-gradient(145deg,#151e2cdd,#0f1621ee);border:1px solid var(--line);"
"border-radius:var(--radius);box-shadow:0 16px 45px #0005}\n"
".status-grid{padding:16px;display:grid;grid-template-columns:repeat(4,minmax(105px,1fr));gap:12px}\n"
".status-item{min-width:0;padding:10px 12px;background:#0b111b99;border:1px solid #202c3e;"
"border-radius:10px}\n"
".status-item.interactive{cursor:pointer;transition:border-color .15s,background .15s,transform .15s}\n"
".status-item.interactive:hover,.status-item.interactive:focus-visible{outline:none;border-color:#4b789f;"
"background:#101c2b;transform:translateY(-1px)}\n"
".status-item.wide{grid-column:span 2}\n"
".label{display:block;color:var(--muted);font-size:11px;font-weight:700;"
"letter-spacing:.08em;text-transform:uppercase;margin-bottom:6px}\n"
".value{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:650}\n"
".controls{padding:16px;display:flex;flex-direction:column;justify-content:center;gap:12px}\n"
".button-row{display:flex;flex-wrap:wrap;gap:9px}\n"
".button{min-height:38px;border:1px solid #36506d;border-radius:9px;padding:8px 13px;"
"background:#18263a;color:var(--text);font-weight:750;cursor:pointer;"
"transition:background .15s,border-color .15s,transform .15s}\n"
".button:hover:not(:disabled){background:#203652;border-color:#4b789f;transform:translateY(-1px)}\n"
".button.primary{background:#126390;border-color:#278abd}\n"
".button.danger{background:#512631;border-color:#81404e}\n"
".button:disabled{cursor:not-allowed;opacity:.42}\n"
".input-state{margin:0;color:var(--warn);font-size:13px}\n"
".input-state[data-enabled=true]{color:var(--good)}\n"
".connection-detail{margin:0;color:var(--muted);font-size:12px;min-height:1.45em}\n"
".session-notice{display:none;margin:0;padding:8px 10px;border:1px solid #64313b;border-radius:8px;"
"background:#351b23;color:#ffd9de;font-size:12px}\n"
".session-notice[data-visible=true]{display:block}\n"
".terminal-panel{min-width:0;min-height:0;padding:10px;display:flex;flex-direction:column;overflow:hidden}\n"
".terminal-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;"
"padding:1px 5px 9px;color:var(--muted);font-size:12px}\n"
".terminal-heading{display:flex;align-items:center;gap:12px;min-width:0}\n"
".terminal-title{color:var(--text);font-weight:750;letter-spacing:.02em}\n"
".terminal-modes{display:inline-flex;padding:2px;border:1px solid var(--line);border-radius:9px;background:#0b111b}\n"
".mode-button{min-height:28px;border:0;border-radius:6px;padding:4px 9px;background:transparent;color:var(--muted);"
"font-size:12px;font-weight:750;cursor:pointer}\n"
".mode-button[aria-selected=true]{background:#1b3851;color:var(--text)}\n"
".terminal-host{flex:1;min-width:0;min-height:0;border-radius:9px;overflow:hidden;"
"background:#080c12;padding:8px}\n"
".terminal-host .xterm{width:100%;height:100%}\n"
".terminal-host .xterm-viewport{border-radius:7px}\n"
".popover{position:fixed;z-index:20;width:min(92vw,520px);max-height:min(72vh,620px);overflow:auto;"
"border:1px solid #3c5877;border-radius:13px;padding:14px;background:#111a28;box-shadow:0 24px 70px #000b}\n"
".popover-head,.settings-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}\n"
".popover h2,.settings h2,.settings h3{margin:0}.popover h2,.settings h2{font-size:17px}.settings h3{font-size:14px}\n"
".icon-button{width:34px;height:34px;border:1px solid var(--line);border-radius:8px;background:#18263a;color:var(--text);cursor:pointer}\n"
".popover-list{display:grid;gap:8px}.client-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;"
"padding:10px;border:1px solid #29364a;border-radius:9px;background:#0b111b}\n"
".client-meta{color:var(--muted);font-size:12px}.quick-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}\n"
".settings-backdrop{position:fixed;z-index:30;inset:0;display:grid;place-items:center;padding:20px;background:#02050acc}\n"
".settings{width:min(96vw,1040px);max-height:90vh;overflow:auto;border:1px solid #3c5877;border-radius:16px;"
"padding:18px;background:#111a28;box-shadow:0 28px 90px #000d}\n"
".settings-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.settings-section{padding:14px;"
"border:1px solid var(--line);border-radius:11px;background:#0b111b}\n"
".settings-section.wide{grid-column:1/-1}.settings-form{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-top:12px}\n"
".settings-form label{display:grid;gap:5px;color:var(--muted);font-size:11px}.settings-form .span-2{grid-column:span 2}.settings-form .span-4{grid-column:1/-1}\n"
".settings input,.settings select,.settings textarea{min-width:0;width:100%;min-height:36px;border:1px solid #344760;border-radius:8px;"
"padding:7px;background:#080c12;color:var(--text);font:inherit}.settings textarea{min-height:72px;resize:vertical}.settings-message{min-height:1.4em;color:var(--muted);font-size:12px}\n"
".settings-subsection{margin-top:12px;padding-top:12px;border-top:1px solid var(--line)}.settings-subsection h4{margin:0;font-size:13px}.secret-state{color:var(--muted);font-size:11px}\n"
".key-list{display:grid;gap:7px;margin-top:10px}.key-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;align-items:center;padding:8px;border:1px solid var(--line);border-radius:8px}\n"
".secret-output{margin-top:10px;padding:10px;border:1px solid #675526;border-radius:8px;background:#2d2613;color:#ffe7a3}.secret-output code{user-select:all;overflow-wrap:anywhere}\n"
"@media(max-width:700px){.settings-grid{grid-template-columns:1fr}.settings-section.wide{grid-column:auto}.settings-form{grid-template-columns:1fr 1fr}}\n"
"@media(max-width:850px){.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}"
".status-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}\n"
"@media(max-width:700px){.topbar{flex-wrap:wrap}.topbar-actions{width:100%}}\n"
"@media(max-width:480px){.page{padding:10px;gap:10px}.topbar{align-items:flex-start}"
".topbar-actions{justify-content:space-between}.account{min-width:0}.account-username{max-width:120px}"
".logo{width:36px;height:36px}.status-grid{padding:10px;gap:8px}"
".status-item{padding:9px}.controls{padding:12px}.terminal-panel{padding:7px}"
".button-row{display:grid;grid-template-columns:1fr 1fr;width:100%}"
".button:last-child{grid-column:1/-1}.page{grid-template-rows:auto auto minmax(0,1fr)}}\n"
"</style>\n"
"<link rel=\"icon\" href=\"data:,\">\n"
"<link rel=\"stylesheet\" href=\"/assets/xterm.css\">\n"
"<script defer src=\"/assets/xterm.js\"></script>\n"
"<script defer src=\"/assets/addon-fit.js\"></script>\n"
"<script defer src=\"/assets/app.js\"></script>\n"
"</head>\n"
"<body>\n"
"<main class=\"page\">\n"
"<header class=\"topbar\">\n"
"<div class=\"brand\"><img class=\"logo\" src=\"/assets/logo.png\""
"alt=\"ESP32 Serial Swiss Army Knife logo\"><div>"
"<h1>ESP32 Serial Console</h1><p class=\"subtitle\">Secure local serial workspace</p></div></div>\n"
"<div class=\"topbar-actions\">\n"
"<div class=\"account\" aria-label=\"Signed-in account\"><span class=\"account-icon\" aria-hidden=\"true\">@</span>"
"<span class=\"account-copy\"><span id=\"account-username\" class=\"account-username\">Loading…</span>"
"<span id=\"account-role\" class=\"account-role\">Account</span></span></div>\n"
"<span id=\"connection-status\" class=\"badge\" data-tone=\"warn\">Connecting</span>\n"
"<button id=\"settings-open\" class=\"button\" type=\"button\" hidden>Settings</button>\n"
"<button id=\"logout\" class=\"button\" type=\"button\" disabled>Logout</button>\n"
"</div>\n"
"</header>\n"
"<section class=\"dashboard\" aria-label=\"Connection and device status\">\n"
"<div class=\"panel status-grid\">\n"
"<div class=\"status-item\"><span class=\"label\">Broker role</span>"
"<span id=\"role-status\" class=\"badge\" data-tone=\"warn\">Observer</span></div>\n"
"<div class=\"status-item\"><span class=\"label\">Broker client</span>"
"<span id=\"client-id\" class=\"value\">—</span></div>\n"
"<div id=\"writer-card\" class=\"status-item\" tabindex=\"-1\" aria-haspopup=\"dialog\"><span class=\"label\">Active writer</span>"
"<span id=\"writer-id\" class=\"value\">None</span></div>\n"
"<div id=\"clients-card\" class=\"status-item\" tabindex=\"-1\" aria-haspopup=\"dialog\"><span class=\"label\">Broker clients</span>"
"<span id=\"broker-clients\" class=\"value\">—</span></div>\n"
"<div id=\"wifi-card\" class=\"status-item wide\" tabindex=\"-1\" aria-haspopup=\"dialog\"><span class=\"label\">Wi-Fi</span>"
"<span id=\"wifi-summary\" class=\"value\">Loading…</span></div>\n"
"<div id=\"serial-card\" class=\"status-item wide\" tabindex=\"-1\" aria-haspopup=\"dialog\"><span class=\"label\">Serial</span>"
"<span id=\"serial-summary\" class=\"value\">Loading…</span></div>\n"
"</div>\n"
"<div class=\"panel controls\">\n"
"<div class=\"button-row\">\n"
"<button id=\"request-control\" class=\"button primary\" type=\"button\" disabled>Request control</button>\n"
"<button id=\"release-control\" class=\"button danger\" type=\"button\" disabled>Release control</button>\n"
"<button id=\"connection-toggle\" class=\"button danger\" type=\"button\">Disconnect</button>\n"
"</div>\n"
"<p id=\"input-state\" class=\"input-state\" data-enabled=\"false\" aria-live=\"polite\">"
"Observer mode — terminal input is disabled.</p>\n"
"<p id=\"connection-detail\" class=\"connection-detail\" aria-live=\"polite\">"
"Requesting a one-time connection ticket…</p>\n"
"<p id=\"session-notice\" class=\"session-notice\" data-visible=\"false\" role=\"alert\"></p>\n"
"</div>\n"
"</section>\n"
"<section class=\"panel terminal-panel\" aria-label=\"Terminal workspace\">\n"
"<div class=\"terminal-toolbar\"><div class=\"terminal-heading\">"
"<span id=\"terminal-title\" class=\"terminal-title\">Live serial stream</span>"
"<div id=\"terminal-modes\" class=\"terminal-modes\" role=\"tablist\" aria-label=\"Terminal mode\" hidden>"
"<button id=\"serial-mode\" class=\"mode-button\" type=\"button\" role=\"tab\" aria-selected=\"true\">Serial terminal</button>"
"<button id=\"admin-mode\" class=\"mode-button\" type=\"button\" role=\"tab\" aria-selected=\"false\">Admin shell</button>"
"</div></div>\n"
"<span id=\"terminal-detail\">Binary, unmodified device output</span></div>\n"
"<div id=\"serial-terminal\" class=\"terminal-host\" role=\"tabpanel\"></div>\n"
"<div id=\"admin-terminal\" class=\"terminal-host\" role=\"tabpanel\" hidden></div>\n"
"</section>\n"
"<aside id=\"quick-popover\" class=\"popover\" role=\"dialog\" aria-modal=\"false\" hidden>"
"<div class=\"popover-head\"><h2 id=\"popover-title\">Quick settings</h2>"
"<button id=\"popover-close\" class=\"icon-button\" type=\"button\" aria-label=\"Close\">×</button></div>"
"<div id=\"popover-content\"></div></aside>\n"
"<div id=\"settings-backdrop\" class=\"settings-backdrop\" hidden>"
"<section class=\"settings\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"settings-title\">"
"<div class=\"settings-head\"><div><h2 id=\"settings-title\">Device settings</h2>"
"<span class=\"client-meta\">Administrator-only typed controls</span></div>"
"<button id=\"settings-close\" class=\"icon-button\" type=\"button\" aria-label=\"Close settings\">×</button></div>"
"<div class=\"settings-grid\">"
"<section class=\"settings-section wide\"><h3>Serial</h3>"
"<form id=\"serial-settings-form\" class=\"settings-form\">"
"<label class=\"span-2\">Baud<input id=\"serial-baud\" name=\"baud\" type=\"number\" min=\"110\" max=\"1000000\"></label>"
"<label>Data bits<select id=\"serial-data\"><option>7</option><option>8</option></select></label>"
"<label>Parity<select id=\"serial-parity\"><option>none</option><option>even</option><option>odd</option></select></label>"
"<label>Stop bits<select id=\"serial-stop-bits\"><option>1</option><option>2</option></select></label>"
"<label>Flow<select id=\"serial-flow\"><option>none</option><option>rts-cts</option></select></label>"
"<label>DTR<select id=\"serial-dtr\"><option>inactive</option><option>active</option><option>on-connect</option></select></label>"
"<label>RTS threshold<input id=\"serial-rts\" type=\"number\" min=\"1\" max=\"127\"></label>"
"</form><div class=\"quick-actions\">"
"<button id=\"serial-apply\" class=\"button primary\" type=\"button\">Apply</button>"
"<button id=\"serial-save\" class=\"button\" type=\"button\">Save</button>"
"<button id=\"serial-start\" class=\"button\" type=\"button\">Start</button>"
"<button id=\"serial-stop\" class=\"button\" type=\"button\">Stop</button>"
"<button id=\"serial-load\" class=\"button\" type=\"button\">Load saved</button>"
"<button id=\"serial-defaults\" class=\"button\" type=\"button\">Defaults</button>"
"<button id=\"serial-reset\" class=\"button danger\" type=\"button\">Reset</button>"
"</div></section>"
"<section class=\"settings-section wide\"><h3>Users and SSH access</h3>"
"<p class=\"client-meta\">Passwords and public keys are write-only. Account changes revoke that account's active browser and SSH sessions.</p>"
"<div class=\"settings-subsection\"><h4>Create account</h4><form id=\"user-create-form\" class=\"settings-form\">"
"<label>Username<input id=\"user-create-name\" autocomplete=\"off\" maxlength=\"16\" pattern=\"[a-z][a-z0-9_-]{0,15}\" required></label>"
"<label>Role<select id=\"user-create-role\"><option value=\"user\">user</option><option value=\"admin\">admin</option></select></label>"
"<label>Password mode<select id=\"user-create-password-mode\"><option value=\"provided\">Set password</option><option value=\"generated\">Generate password</option></select></label>"
"<label>Password<input id=\"user-create-password\" type=\"password\" autocomplete=\"new-password\" minlength=\"12\" maxlength=\"64\"></label>"
"</form><div class=\"quick-actions\"><button id=\"user-create\" class=\"button primary\" type=\"button\">Create account</button></div></div>"
"<div class=\"settings-subsection\"><h4>Edit account</h4><form id=\"user-edit-form\" class=\"settings-form\">"
"<label class=\"span-2\">Account<select id=\"user-select\"></select></label>"
"<label>Role<select id=\"user-role\"><option value=\"user\">user</option><option value=\"admin\">admin</option></select></label>"
"<label>Identity<span id=\"user-identity\" class=\"value\">—</span></label>"
"<label class=\"span-2\">New password<input id=\"user-password\" type=\"password\" autocomplete=\"new-password\" minlength=\"12\" maxlength=\"64\"></label>"
"<label class=\"span-2\">Authorized public key<input id=\"user-key\" autocomplete=\"off\" spellcheck=\"false\" placeholder=\"ssh-ed25519 AAAA… optional-comment\"></label>"
"</form><div class=\"quick-actions\"><button id=\"user-role-apply\" class=\"button\" type=\"button\">Apply role</button>"
"<button id=\"user-password-set\" class=\"button\" type=\"button\">Set password</button>"
"<button id=\"user-password-generate\" class=\"button\" type=\"button\">Generate password</button>"
"<button id=\"user-key-add\" class=\"button\" type=\"button\">Add key</button>"
"<button id=\"user-key-clear\" class=\"button danger\" type=\"button\">Clear all keys</button>"
"<button id=\"user-delete\" class=\"button danger\" type=\"button\">Delete account</button></div>"
"<div id=\"user-keys\" class=\"key-list\"></div>"
"<div id=\"generated-password-box\" class=\"secret-output\" hidden><strong>One-time generated password</strong><br>"
"<code id=\"generated-password\"></code><div class=\"quick-actions\"><button id=\"generated-password-copy\" class=\"button\" type=\"button\">Copy</button>"
"<button id=\"generated-password-clear\" class=\"button\" type=\"button\">Clear</button></div></div></div></section>"
"<section class=\"settings-section wide\"><h3>Wi-Fi configuration</h3>"
"<p class=\"client-meta\">Saved secrets are never displayed. A stale form is rejected rather than overwriting newer changes.</p>"
"<div class=\"settings-subsection\"><h4>Station profile</h4><form id=\"wifi-profile-form\" class=\"settings-form\">"
"<label>Slot<select id=\"wifi-profile-slot\"><option value=\"0\">0</option><option value=\"1\">1</option><option value=\"2\">2</option><option value=\"3\">3</option></select></label>"
"<label class=\"span-2\">SSID<input id=\"wifi-profile-ssid\" maxlength=\"32\" autocomplete=\"off\"></label>"
"<label>Priority<input id=\"wifi-profile-priority\" type=\"number\" min=\"0\" max=\"255\"></label>"
"<label>Security<select id=\"wifi-profile-security\"><option value=\"mixed\">WPA2 or stronger</option><option value=\"wpa3\">WPA3 only</option></select></label>"
"<label>Status<span id=\"wifi-profile-status\" class=\"value\">—</span></label>"
"<label class=\"span-2\">New secret<input id=\"wifi-profile-secret\" type=\"password\" autocomplete=\"new-password\" minlength=\"8\" maxlength=\"63\"><span id=\"wifi-profile-secret-state\" class=\"secret-state\"></span></label>"
"</form><div class=\"quick-actions\"><button id=\"wifi-profile-apply\" class=\"button primary\" type=\"button\">Apply profile</button>"
"<button id=\"wifi-profile-toggle\" class=\"button\" type=\"button\">Enable</button>"
"<button id=\"wifi-profile-secret-set\" class=\"button\" type=\"button\">Set secret</button>"
"<button id=\"wifi-profile-delete\" class=\"button danger\" type=\"button\">Delete profile</button></div></div>"
"<div class=\"settings-subsection\"><h4>Fallback access point</h4><form id=\"wifi-ap-form\" class=\"settings-form\">"
"<label>Policy<select id=\"wifi-ap-policy\"><option value=\"off\">off</option><option value=\"fallback\">fallback</option><option value=\"always\">always</option></select></label>"
"<label class=\"span-2\">SSID<input id=\"wifi-ap-ssid\" maxlength=\"32\" autocomplete=\"off\"></label>"
"<label>Channel<input id=\"wifi-ap-channel\" type=\"number\" min=\"1\" max=\"11\"></label>"
"<label class=\"span-2\">New secret<input id=\"wifi-ap-secret\" type=\"password\" autocomplete=\"new-password\" minlength=\"8\" maxlength=\"63\"><span id=\"wifi-ap-secret-state\" class=\"secret-state\"></span></label>"
"</form><div class=\"quick-actions\"><button id=\"wifi-ap-apply\" class=\"button primary\" type=\"button\">Apply AP</button>"
"<button id=\"wifi-ap-secret-set\" class=\"button\" type=\"button\">Set AP secret</button>"
"<button id=\"wifi-config-save\" class=\"button\" type=\"button\">Save Wi-Fi configuration</button></div></div></section>"
"<section class=\"settings-section\"><h3>Display aging</h3><p id=\"display-state\" class=\"client-meta\">Local OLED status unavailable.</p>"
"<form id=\"display-form\" class=\"settings-form\"><label class=\"span-2\">Dim after seconds<input id=\"display-dim\" type=\"number\" min=\"0\" max=\"86400\" required></label>"
"<label class=\"span-2\">Off after seconds<input id=\"display-off\" type=\"number\" min=\"0\" max=\"86400\" required></label></form>"
"<div class=\"quick-actions\"><button id=\"display-apply\" class=\"button primary\" type=\"button\">Apply</button>"
"<button id=\"display-save\" class=\"button\" type=\"button\">Save</button><button id=\"display-load\" class=\"button\" type=\"button\">Load saved</button>"
"<button id=\"display-defaults\" class=\"button\" type=\"button\">Defaults</button><button id=\"display-reset\" class=\"button danger\" type=\"button\">Reset</button></div></section>"
"<section class=\"settings-section\"><h3>Network lifecycle</h3><p class=\"client-meta\">Lifecycle actions never reveal saved Wi-Fi secrets.</p>"
"<div class=\"quick-actions\"><button class=\"button\" data-wifi-action=\"start\">Start</button>"
"<button class=\"button\" data-wifi-action=\"stop\">Stop</button>"
"<button class=\"button primary\" data-wifi-action=\"reconnect\">Reconnect</button>"
"<button class=\"button\" data-wifi-action=\"next-profile\">Next profile</button></div></section>"
"<section class=\"settings-section\"><h3>Full administration</h3>"
"<p class=\"client-meta\">Services, diagnostics, security material, display settings, and uncommon operations remain available through the canonical Admin shell.</p>"
"<div class=\"quick-actions\"><button id=\"settings-admin-shell\" class=\"button primary\" type=\"button\">Open Admin shell</button></div></section>"
"</div><p id=\"settings-message\" class=\"settings-message\" aria-live=\"polite\"></p>"
"</section></div>\n"
"</main>\n"
"</body>\n"
"</html>\n";
#define WEB_UI_LOGIN_PREFIX \
"<!doctype html>\n" \
"<html lang=\"en\">\n" \
"<head>\n" \
"<meta charset=\"utf-8\">\n" \
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n" \
"<meta name=\"color-scheme\" content=\"dark\">\n" \
"<title>Sign in · ESP32 Serial Console</title>\n" \
"<style>\n" \
":root{color-scheme:dark;--bg:#090d14;--panel:#111824;--line:#29364a;" \
"--text:#e8eef8;--muted:#91a0b5;--accent:#55c2ff;--bad:#ff6b7a}\n" \
"*{box-sizing:border-box}\n" \
"html,body{min-height:100%;margin:0}\n" \
"body{min-height:100vh;min-height:100dvh;display:grid;place-items:center;padding:24px;" \
"background:radial-gradient(circle at 15% 5%,#17304a 0,#101827 24rem,var(--bg) 52rem);" \
"color:var(--text);font:14px/1.5 system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif}\n" \
".shell{width:min(100%,430px)}\n" \
".brand{display:flex;align-items:center;gap:14px;margin:0 0 18px;padding:0 4px}\n" \
".logo{width:52px;height:52px;display:block;object-fit:contain;padding:5px;border:1px solid #347ba5;" \
"border-radius:14px;background:#10273a;box-shadow:0 0 34px #1c8fc344}\n" \
".brand strong{display:block;font-size:17px;line-height:1.2}\n" \
".brand span{display:block;margin-top:3px;color:var(--muted);font-size:12px}\n" \
".panel{padding:clamp(24px,7vw,36px);border:1px solid var(--line);border-radius:18px;" \
"background:linear-gradient(145deg,#151e2cf2,#0e151ff7);box-shadow:0 24px 70px #0008}\n" \
".eyebrow{margin:0 0 6px;color:var(--accent);font-size:11px;font-weight:800;letter-spacing:.12em;" \
"text-transform:uppercase}\n" \
"h1{margin:0;font-size:clamp(25px,7vw,32px);line-height:1.15}\n" \
".intro{margin:10px 0 24px;color:var(--muted)}\n" \
".alert{margin:0 0 20px;padding:12px 14px;border:1px solid #713943;border-radius:10px;" \
"background:#351b23;color:#ffd9de}\n" \
".alert strong,.alert span{display:block}.alert span{margin-top:2px;font-size:13px}\n" \
"form{display:grid;gap:16px}\n" \
"label{display:grid;gap:7px;color:#cbd6e6;font-size:12px;font-weight:750;letter-spacing:.03em}\n" \
"input{width:100%;min-height:46px;border:1px solid #344760;border-radius:10px;padding:10px 12px;" \
"outline:none;background:#0a1019;color:var(--text);font:inherit;transition:border-color .15s,box-shadow .15s}\n" \
"input:focus{border-color:var(--accent);box-shadow:0 0 0 3px #55c2ff22}\n" \
"button{min-height:46px;margin-top:4px;border:1px solid #278abd;border-radius:10px;padding:10px 16px;" \
"background:linear-gradient(180deg,#1473a5,#105a83);color:white;font:inherit;font-weight:800;cursor:pointer;" \
"box-shadow:0 9px 24px #08618b35}\n" \
"button:hover{background:linear-gradient(180deg,#1982b9,#126590)}button:focus-visible{outline:3px solid #55c2ff55;outline-offset:2px}\n" \
".privacy{margin:18px 4px 0;color:#718096;text-align:center;font-size:11px}\n" \
"@media(max-width:480px){body{padding:16px}.panel{border-radius:15px}}\n" \
"</style>\n" \
"<link rel=\"icon\" href=\"data:,\">\n" \
"</head>\n" \
"<body>\n" \
"<main class=\"shell\">\n" \
"<div class=\"brand\"><img class=\"logo\" src=\"/assets/logo.png\" " \
"alt=\"ESP32 Serial Swiss Army Knife logo\"><div><strong>ESP32 Serial Console</strong>" \
"<span>Secure local serial workspace</span></div></div>\n" \
"<section class=\"panel\" aria-labelledby=\"login-title\">\n" \
"<p class=\"eyebrow\">Device access</p>\n" \
"<h1 id=\"login-title\">Sign in</h1>\n" \
"<p class=\"intro\">Use your local device account to continue.</p>\n"
#define WEB_UI_LOGIN_FORM \
"<form method=\"post\" action=\"/login\" accept-charset=\"UTF-8\">\n" \
"<label for=\"username\">Username" \
"<input id=\"username\" name=\"username\" type=\"text\" autocomplete=\"username\" " \
"autocapitalize=\"none\" spellcheck=\"false\" required autofocus></label>\n" \
"<label for=\"password\">Password" \
"<input id=\"password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" required></label>\n" \
"<button type=\"submit\">Sign in</button>\n" \
"</form>\n" \
"</section>\n" \
"<p class=\"privacy\">Credentials are sent only to this device over HTTPS.</p>\n" \
"</main>\n" \
"</body>\n" \
"</html>\n"
static const char s_login_html[] =
WEB_UI_LOGIN_PREFIX
WEB_UI_LOGIN_FORM;
static const char s_login_error_html[] =
WEB_UI_LOGIN_PREFIX
"<div class=\"alert\" role=\"alert\"><strong>Sign-in failed.</strong>"
"<span>The username or password was not accepted. Try again.</span></div>\n"
WEB_UI_LOGIN_FORM;
#undef WEB_UI_LOGIN_FORM
#undef WEB_UI_LOGIN_PREFIX
static const char s_app_js[] =
"(() => {\n"
"'use strict';\n"
"const element = (id) => document.getElementById(id);\n"
"const connectionStatus = element('connection-status');\n"
"const accountUsernameField = element('account-username');\n"
"const accountRoleField = element('account-role');\n"
"const logoutButton = element('logout');\n"
"const settingsOpen = element('settings-open');\n"
"const settingsBackdrop = element('settings-backdrop');\n"
"const settingsClose = element('settings-close');\n"
"const settingsMessage = element('settings-message');\n"
"const settingsAdminShell = element('settings-admin-shell');\n"
"const serialSettingsForm = element('serial-settings-form');\n"
"const serialBaud = element('serial-baud');\n"
"const serialData = element('serial-data');\n"
"const serialParity = element('serial-parity');\n"
"const serialStopBits = element('serial-stop-bits');\n"
"const serialFlow = element('serial-flow');\n"
"const serialDtr = element('serial-dtr');\n"
"const serialRts = element('serial-rts');\n"
"const userCreateForm = element('user-create-form');\n"
"const userCreateName = element('user-create-name');\n"
"const userCreateRole = element('user-create-role');\n"
"const userCreatePasswordMode = element('user-create-password-mode');\n"
"const userCreatePassword = element('user-create-password');\n"
"const userEditForm = element('user-edit-form');\n"
"const userSelect = element('user-select');\n"
"const userRole = element('user-role');\n"
"const userIdentity = element('user-identity');\n"
"const userPassword = element('user-password');\n"
"const userKey = element('user-key');\n"
"const userKeys = element('user-keys');\n"
"const generatedPasswordBox = element('generated-password-box');\n"
"const generatedPassword = element('generated-password');\n"
"const wifiProfileForm = element('wifi-profile-form');\n"
"const wifiProfileSlot = element('wifi-profile-slot');\n"
"const wifiProfileSsid = element('wifi-profile-ssid');\n"
"const wifiProfilePriority = element('wifi-profile-priority');\n"
"const wifiProfileSecurity = element('wifi-profile-security');\n"
"const wifiProfileStatus = element('wifi-profile-status');\n"
"const wifiProfileSecret = element('wifi-profile-secret');\n"
"const wifiProfileSecretState = element('wifi-profile-secret-state');\n"
"const wifiProfileToggle = element('wifi-profile-toggle');\n"
"const wifiApForm = element('wifi-ap-form');\n"
"const wifiApPolicy = element('wifi-ap-policy');\n"
"const wifiApSsid = element('wifi-ap-ssid');\n"
"const wifiApChannel = element('wifi-ap-channel');\n"
"const wifiApSecret = element('wifi-ap-secret');\n"
"const wifiApSecretState = element('wifi-ap-secret-state');\n"
"const displayForm = element('display-form');\n"
"const displayDim = element('display-dim');\n"
"const displayOff = element('display-off');\n"
"const displayState = element('display-state');\n"
"const roleStatus = element('role-status');\n"
"const clientIdField = element('client-id');\n"
"const writerIdField = element('writer-id');\n"
"const brokerClientsField = element('broker-clients');\n"
"const writerCard = element('writer-card');\n"
"const clientsCard = element('clients-card');\n"
"const wifiCard = element('wifi-card');\n"
"const serialCard = element('serial-card');\n"
"const quickPopover = element('quick-popover');\n"
"const popoverTitle = element('popover-title');\n"
"const popoverContent = element('popover-content');\n"
"const popoverClose = element('popover-close');\n"
"const wifiSummary = element('wifi-summary');\n"
"const serialSummary = element('serial-summary');\n"
"const inputState = element('input-state');\n"
"const connectionDetail = element('connection-detail');\n"
"const sessionNotice = element('session-notice');\n"
"const requestControl = element('request-control');\n"
"const releaseControl = element('release-control');\n"
"const connectionToggle = element('connection-toggle');\n"
"const terminalTitle = element('terminal-title');\n"
"const terminalDetail = element('terminal-detail');\n"
"const terminalModes = element('terminal-modes');\n"
"const serialModeButton = element('serial-mode');\n"
"const adminModeButton = element('admin-mode');\n"
"const serialTerminalHost = element('serial-terminal');\n"
"const adminTerminalHost = element('admin-terminal');\n"
"const terminalOptions = {\n"
" allowProposedApi: false, convertEol: false, cursorBlink: true, disableStdin: true,\n"
" fontFamily: '\"SFMono-Regular\",Consolas,\"Liberation Mono\",monospace',\n"
" fontSize: 14, scrollback: 5000, theme: {\n"
" background: '#080c12', foreground: '#dce7f5', cursor: '#55c2ff',\n"
" selectionBackground: '#285173', black: '#18202b', red: '#ff6b7a',\n"
" green: '#52d68b', yellow: '#ffc857', blue: '#55aaff',\n"
" magenta: '#c792ea', cyan: '#55d6be', white: '#e8eef8'\n"
" }\n"
"};\n"
"const serialTerminal = new Terminal(terminalOptions);\n"
"const adminTerminal = new Terminal({...terminalOptions, disableStdin: false});\n"
"const serialFitAddon = new FitAddon.FitAddon();\n"
"const adminFitAddon = new FitAddon.FitAddon();\n"
"serialTerminal.loadAddon(serialFitAddon);\n"
"adminTerminal.loadAddon(adminFitAddon);\n"
"serialTerminal.open(serialTerminalHost);\n"
"adminTerminal.open(adminTerminalHost);\n"
"const encoder = new TextEncoder();\n"
"let socket = null;\n"
"let adminSocket = null;\n"
"let adminTicketAbort = null;\n"
"let adminConnectionGeneration = 0;\n"
"let terminalMode = 'serial';\n"
"let sessionAbort = null;\n"
"let ticketAbort = null;\n"
"let reconnectTimer = null;\n"
"let reconnectDelay = 1000;\n"
"let reconnectEnabled = true;\n"
"let connectionGeneration = 0;\n"
"let brokerRole = 'observer';\n"
"let accountUsername = null;\n"
"let accountRole = null;\n"
"let csrfToken = null;\n"
"let sessionReady = false;\n"
"let startupInFlight = false;\n"
"let authRedirecting = false;\n"
"let clientId = null;\n"
"let writerId = 0;\n"
"let unloading = false;\n"
"let fitFrame = 0;\n"
"let serialLastFitWidth = 0;\n"
"let serialLastFitHeight = 0;\n"
"let adminLastFitWidth = 0;\n"
"let adminLastFitHeight = 0;\n"
"let statusInFlight = false;\n"
"let statusTimer = null;\n"
"let popoverTrigger = null;\n"
"let popoverGeneration = 0;\n"
"let usersState = null;\n"
"let wifiConfigState = null;\n"
"const setBadge = (target, text, tone) => {\n"
" target.textContent = text;\n"
" target.dataset.tone = tone;\n"
"};\n"
"const validId = (value) => Number.isSafeInteger(value) && value >= 0;\n"
"const displayId = (value, noneText) => validId(value) && value !== 0 ? String(value) : noneText;\n"
"const socketOpen = () => socket !== null && socket.readyState === WebSocket.OPEN;\n"
"const adminSocketOpen = () => adminSocket !== null && adminSocket.readyState === WebSocket.OPEN;\n"
"const updateControls = () => {\n"
" const writer = brokerRole === 'writer';\n"
" serialTerminal.options.disableStdin = !writer;\n"
" requestControl.disabled = !socketOpen() || writer;\n"
" releaseControl.disabled = !socketOpen() || !writer;\n"
" const connectionActive = reconnectEnabled || socket !== null || ticketAbort !== null || reconnectTimer !== null;\n"
" connectionToggle.disabled = unloading || authRedirecting || startupInFlight;\n"
" logoutButton.disabled = unloading || authRedirecting || !sessionReady;\n"
" connectionToggle.textContent = connectionActive ? 'Disconnect serial' : 'Connect serial';\n"
" connectionToggle.classList.toggle('danger', connectionActive);\n"
" inputState.dataset.enabled = writer ? 'true' : 'false';\n"
" inputState.textContent = writer\n"
" ? 'Writer mode — terminal input is enabled.'\n"
" : 'Observer mode — terminal input is disabled.';\n"
" setBadge(roleStatus, writer ? 'Writer' : 'Observer', writer ? 'good' : 'warn');\n"
"};\n"
"const setBrokerRole = (nextRole) => {\n"
" brokerRole = nextRole === 'writer' ? 'writer' : 'observer';\n"
" updateControls();\n"
"};\n"
"const setConnection = (text, tone, detail) => {\n"
" setBadge(connectionStatus, text, tone);\n"
" connectionDetail.textContent = detail;\n"
" updateControls();\n"
"};\n"
"const setSessionNotice = (message) => {\n"
" const visible = typeof message === 'string' && message.length > 0;\n"
" sessionNotice.textContent = visible ? message : '';\n"
" sessionNotice.dataset.visible = visible ? 'true' : 'false';\n"
"};\n"
"const setTerminalMode = (nextMode) => {\n"
" const admin = nextMode === 'admin' && accountRole === 'admin';\n"
" terminalMode = admin ? 'admin' : 'serial';\n"
" serialTerminalHost.hidden = admin;\n"
" adminTerminalHost.hidden = !admin;\n"
" serialModeButton.setAttribute('aria-selected', admin ? 'false' : 'true');\n"
" adminModeButton.setAttribute('aria-selected', admin ? 'true' : 'false');\n"
" terminalTitle.textContent = admin ? 'Administrative shell' : 'Live serial stream';\n"
" terminalDetail.textContent = admin\n"
" ? 'Canonical device administration · serial connection and writer lease remain active'\n"
" : 'Binary, unmodified device output';\n"
" if (admin) {\n"
" adminLastFitWidth = 0; adminLastFitHeight = 0;\n"
" if (!adminSocketOpen() && adminTicketAbort === null) connectAdmin();\n"
" adminTerminal.focus();\n"
" } else {\n"
" serialLastFitWidth = 0; serialLastFitHeight = 0;\n"
" serialTerminal.focus();\n"
" }\n"
" scheduleFit();\n"
"};\n"
"const clearReconnectTimer = () => {\n"
" if (reconnectTimer !== null) {\n"
" window.clearTimeout(reconnectTimer);\n"
" reconnectTimer = null;\n"
" }\n"
"};\n"
"const stopAuthenticatedActivity = () => {\n"
" reconnectEnabled = false;\n"
" sessionReady = false;\n"
" ++connectionGeneration;\n"
" ++adminConnectionGeneration;\n"
" clearReconnectTimer();\n"
" if (statusTimer !== null) { window.clearInterval(statusTimer); statusTimer = null; }\n"
" if (sessionAbort !== null) { sessionAbort.abort(); sessionAbort = null; }\n"
" if (ticketAbort !== null) { ticketAbort.abort(); ticketAbort = null; }\n"
" if (adminTicketAbort !== null) { adminTicketAbort.abort(); adminTicketAbort = null; }\n"
" if (socket !== null) { const previous = socket; socket = null; previous.close(); }\n"
" if (adminSocket !== null) { const previous = adminSocket; adminSocket = null; previous.close(); }\n"
" clientId = null;\n"
" clientIdField.textContent = '—';\n"
" usersState = null;\n"
" wifiConfigState = null;\n"
" clearGeneratedPassword();\n"
" setBrokerRole('observer');\n"
" updateControls();\n"
"};\n"
"const navigateToLogin = () => {\n"
" if (authRedirecting || unloading) return;\n"
" authRedirecting = true;\n"
" stopAuthenticatedActivity();\n"
" csrfToken = null;\n"
" accountUsername = null;\n"
" accountRole = null;\n"
" window.location.replace('/login');\n"
"};\n"
"const scheduleReconnect = () => {\n"
" if (unloading || authRedirecting || !sessionReady || !reconnectEnabled || reconnectTimer !== null) return;\n"
" const delay = reconnectDelay;\n"
" reconnectDelay = Math.min(reconnectDelay * 2, 10000);\n"
" setConnection('Disconnected', 'bad', `Reconnecting in ${Math.ceil(delay / 1000)} second(s)…`);\n"
" reconnectTimer = window.setTimeout(() => {\n"
" reconnectTimer = null;\n"
" connect();\n"
" }, delay);\n"
"};\n"
"const acceptBrokerMessage = (message) => {\n"
" if (message === null || typeof message !== 'object') return;\n"
" if (message.type !== 'hello' && message.type !== 'writer') return;\n"
" if (message.role !== 'writer' && message.role !== 'observer') return;\n"
" if (!validId(message.writerId)) return;\n"
" if (message.type === 'hello') {\n"
" if (!validId(message.clientId) || message.clientId === 0) return;\n"
" clientId = message.clientId;\n"
" clientIdField.textContent = String(clientId);\n"
" reconnectDelay = 1000;\n"
" }\n"
" writerId = message.writerId;\n"
" writerIdField.textContent = displayId(writerId, 'None');\n"
" setBrokerRole(message.role);\n"
"};\n"
"const handleSocketMessage = (event) => {\n"
" if (typeof event.data === 'string') {\n"
" try {\n"
" acceptBrokerMessage(JSON.parse(event.data));\n"
" } catch (_) {\n"
" setConnection('Protocol error', 'bad', 'The device sent an invalid control message.');\n"
" }\n"
" return;\n"
" }\n"
" if (event.data instanceof ArrayBuffer) {\n"
" serialTerminal.write(new Uint8Array(event.data));\n"
" }\n"
"};\n"
"async function requestTicket(signal) {\n"
" if (!sessionReady || typeof csrfToken !== 'string') throw new Error('session unavailable');\n"
" const response = await fetch('/api/ws-ticket', {\n"
" method: 'POST', credentials: 'same-origin', cache: 'no-store', signal,\n"
" headers: {'X-CSRF-Token': csrfToken}\n"
" });\n"
" if (response.status === 401) {\n"
" navigateToLogin();\n"
" throw new Error('session expired');\n"
" }\n"
" if (!response.ok) throw new Error('ticket request failed');\n"
" const payload = await response.json();\n"
" if (payload === null || typeof payload !== 'object' ||\n"
" typeof payload.ticket !== 'string' || !/^[A-Za-z0-9_-]{32}$/.test(payload.ticket)) {\n"
" throw new Error('invalid ticket response');\n"
" }\n"
" const ticket = payload.ticket;\n"
" payload.ticket = '';\n"
" return ticket;\n"
"}\n"
"async function connect() {\n"
" if (unloading || authRedirecting || !sessionReady || !reconnectEnabled) return;\n"
" clearReconnectTimer();\n"
" const generation = ++connectionGeneration;\n"
" if (ticketAbort !== null) ticketAbort.abort();\n"
" ticketAbort = new AbortController();\n"
" if (socket !== null) {\n"
" const previous = socket;\n"
" socket = null;\n"
" previous.close();\n"
" }\n"
" clientId = null;\n"
" clientIdField.textContent = '—';\n"
" setBrokerRole('observer');\n"
" setConnection('Connecting', 'warn', 'Requesting a one-time connection ticket…');\n"
" try {\n"
" const ticket = await requestTicket(ticketAbort.signal);\n"
" if (unloading || generation !== connectionGeneration) return;\n"
" ticketAbort = null;\n"
" const url = new URL('/ws/serial', window.location.origin);\n"
" url.protocol = 'wss:';\n"
" url.searchParams.set('ticket', ticket);\n"
" const nextSocket = new WebSocket(url.toString());\n"
" url.search = '';\n"
" nextSocket.binaryType = 'arraybuffer';\n"
" socket = nextSocket;\n"
" nextSocket.addEventListener('open', () => {\n"
" if (socket !== nextSocket) return;\n"
" setConnection('Connected', 'good', 'Connected; waiting for broker role information.');\n"
" });\n"
" nextSocket.addEventListener('message', (event) => {\n"
" if (socket === nextSocket) handleSocketMessage(event);\n"
" });\n"
" nextSocket.addEventListener('error', () => {\n"
" if (socket === nextSocket) {\n"
" setConnection('Connection error', 'bad', 'The WebSocket connection failed.');\n"
" }\n"
" });\n"
" nextSocket.addEventListener('close', () => {\n"
" if (socket !== nextSocket) return;\n"
" socket = null;\n"
" clientId = null;\n"
" clientIdField.textContent = '—';\n"
" setBrokerRole('observer');\n"
" scheduleReconnect();\n"
" });\n"
" } catch (error) {\n"
" if (generation !== connectionGeneration || unloading || authRedirecting || error.name === 'AbortError') return;\n"
" ticketAbort = null;\n"
" scheduleReconnect();\n"
" }\n"
"}\n"
"async function requestAdminTicket(signal) {\n"
" if (!sessionReady || accountRole !== 'admin' || typeof csrfToken !== 'string') throw new Error('admin session unavailable');\n"
" const response = await fetch('/api/admin/ws-ticket', {\n"
" method: 'POST', credentials: 'same-origin', cache: 'no-store', signal,\n"
" headers: {'X-CSRF-Token': csrfToken}\n"
" });\n"
" if (response.status === 401) { navigateToLogin(); throw new Error('session expired'); }\n"
" if (response.status === 403) throw new Error('administrator access required');\n"
" if (!response.ok) throw new Error('admin ticket request failed');\n"
" const payload = await response.json();\n"
" if (payload === null || typeof payload !== 'object' ||\n"
" typeof payload.ticket !== 'string' || !/^[A-Za-z0-9_-]{32}$/.test(payload.ticket)) {\n"
" throw new Error('invalid admin ticket response');\n"
" }\n"
" const ticket = payload.ticket;\n"
" payload.ticket = '';\n"
" return ticket;\n"
"}\n"
"async function connectAdmin() {\n"
" if (unloading || authRedirecting || !sessionReady || accountRole !== 'admin' ||\n"
" adminSocket !== null || adminTicketAbort !== null) return;\n"
" const generation = ++adminConnectionGeneration;\n"
" const controller = new AbortController();\n"
" adminTicketAbort = controller;\n"
" adminTerminal.write('\\r\\n[Opening administrative shell…]\\r\\n');\n"
" try {\n"
" const ticket = await requestAdminTicket(controller.signal);\n"
" if (unloading || authRedirecting || generation !== adminConnectionGeneration) return;\n"
" adminTicketAbort = null;\n"
" const url = new URL('/ws/admin', window.location.origin);\n"
" url.protocol = 'wss:';\n"
" url.searchParams.set('ticket', ticket);\n"
" const nextSocket = new WebSocket(url.toString());\n"
" url.search = '';\n"
" nextSocket.binaryType = 'arraybuffer';\n"
" adminSocket = nextSocket;\n"
" nextSocket.addEventListener('open', () => {\n"
" if (adminSocket === nextSocket && terminalMode === 'admin') adminTerminal.focus();\n"
" });\n"
" nextSocket.addEventListener('message', (event) => {\n"
" if (adminSocket === nextSocket && event.data instanceof ArrayBuffer) {\n"
" adminTerminal.write(new Uint8Array(event.data));\n"
" }\n"
" });\n"
" nextSocket.addEventListener('error', () => {\n"
" if (adminSocket === nextSocket) adminTerminal.write('\\r\\n[Administrative shell connection error.]\\r\\n');\n"
" });\n"
" nextSocket.addEventListener('close', () => {\n"
" if (adminSocket !== nextSocket) return;\n"
" adminSocket = null;\n"
" adminTerminal.write('\\r\\n[Administrative shell closed. Select Admin shell to reconnect.]\\r\\n');\n"
" });\n"
" } catch (error) {\n"
" if (generation !== adminConnectionGeneration || unloading || authRedirecting || error.name === 'AbortError') return;\n"
" adminTicketAbort = null;\n"
" adminTerminal.write(`\\r\\n[Could not open administrative shell: ${error.message}]\\r\\n`);\n"
" }\n"
"}\n"
"async function adminRequest(path, method = 'GET', fields = null) {\n"
" if (!sessionReady || accountRole !== 'admin') throw new Error('administrator access required');\n"
" const options = {method, credentials: 'same-origin', cache: 'no-store', headers: {}};\n"
" if (method === 'POST') {\n"
" if (typeof csrfToken !== 'string') throw new Error('session unavailable');\n"
" options.headers['X-CSRF-Token'] = csrfToken;\n"
" options.headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';\n"
" options.body = new URLSearchParams(fields);\n"
" }\n"
" const response = await fetch(path, options);\n"
" if (response.status === 401) { navigateToLogin(); throw new Error('session expired'); }\n"
" if (response.status === 403) throw new Error('administrator access required');\n"
" if (!response.ok) {\n"
" const contentType = response.headers.get('content-type') || '';\n"
" let message = 'operation failed'; let code = null;\n"
" if (contentType.includes('application/json')) {\n"
" try {\n"
" const payload = await response.json();\n"
" if (typeof payload?.message === 'string' && payload.message.length > 0) message = payload.message;\n"
" if (typeof payload?.error === 'string' && /^[a-z_]+$/.test(payload.error)) code = payload.error;\n"
" } catch (_) {}\n"
" } else {\n"
" const text = (await response.text()).trim(); if (text.length > 0) message = text;\n"
" }\n"
" const error = new Error(message); error.status = response.status; error.code = code;\n"
" throw error;\n"
" }\n"
" return response.headers.get('content-type')?.includes('application/json') ? response.json() : null;\n"
"}\n"
"const clearGeneratedPassword = () => {\n"
" generatedPassword.textContent = '';\n"
" generatedPasswordBox.hidden = true;\n"
"};\n"
"const showGeneratedPassword = (password) => {\n"
" clearGeneratedPassword();\n"
" generatedPassword.textContent = password;\n"
" generatedPasswordBox.hidden = false;\n"
"};\n"
"const selectedUser = () => {\n"
" if (!usersState || !Array.isArray(usersState.users)) return null;\n"
" return usersState.users.find((user) => String(user.id) === userSelect.value) || null;\n"
"};\n"
"const renderSelectedUser = () => {\n"
" const user = selectedUser();\n"
" const available = user !== null;\n"
" userRole.disabled = !available;\n"
" userPassword.disabled = !available;\n"
" userKey.disabled = !available;\n"
" for (const id of ['user-role-apply','user-password-set','user-password-generate','user-key-add','user-key-clear','user-delete']) {\n"
" element(id).disabled = !available;\n"
" }\n"
" userKeys.replaceChildren();\n"
" if (!available) {\n"
" userIdentity.textContent = '—';\n"
" return;\n"
" }\n"
" userRole.value = user.role;\n"
" userIdentity.textContent = `ID ${user.id} · auth generation ${user.auth_generation}`;\n"
" element('user-password-generate').disabled = user.username === accountUsername;\n"
" const hasKeys = Array.isArray(user.keys) && user.keys.length > 0;\n"
" element('user-key-clear').disabled = !hasKeys;\n"
" if (!hasKeys) {\n"
" const empty = document.createElement('p');\n"
" empty.className = 'client-meta';\n"
" empty.textContent = 'No authorized SSH public keys.';\n"
" userKeys.append(empty);\n"
" return;\n"
" }\n"
" for (const key of user.keys) {\n"
" const row = document.createElement('div'); row.className = 'key-row';\n"
" const copy = document.createElement('div');\n"
" const title = document.createElement('strong'); title.textContent = `${key.type} · slot ${key.index}`;\n"
" const fingerprint = document.createElement('div'); fingerprint.className = 'client-meta'; fingerprint.textContent = key.fingerprint;\n"
" copy.append(title, fingerprint);\n"
" const remove = makeButton('Remove', async () => {\n"
" if (!window.confirm(`Remove SSH key slot ${key.index} from ${user.username}?`)) return;\n"
" await runSettingsAction(() => userOperation('key-delete', {key_index: String(key.index), confirm: 'true'}, user));\n"
" });\n"
" row.append(copy, remove); userKeys.append(row);\n"
" }\n"
"};\n"
"async function loadUsers(preferredUserId = null) {\n"
" const payload = await adminRequest('/api/admin/users');\n"
" if (!payload || !Number.isSafeInteger(payload.generation) || payload.generation < 1 || !Array.isArray(payload.users)) {\n"
" throw new Error('invalid user response');\n"
" }\n"
" const previous = preferredUserId !== null ? String(preferredUserId) : userSelect.value;\n"
" usersState = payload;\n"
" userSelect.replaceChildren();\n"
" for (const user of payload.users) {\n"
" if (!validId(user.id) || typeof user.username !== 'string' || (user.role !== 'user' && user.role !== 'admin')) continue;\n"
" const option = document.createElement('option'); option.value = String(user.id); option.textContent = `${user.username} (${user.role})`; userSelect.append(option);\n"
" }\n"
" if ([...userSelect.options].some((option) => option.value === previous)) userSelect.value = previous;\n"
" renderSelectedUser();\n"
" return payload;\n"
"}\n"
"const printableAscii = (value, minimum, maximum) => value.length >= minimum && value.length <= maximum &&\n"
" [...value].every((character) => character.codePointAt(0) >= 0x20 && character.codePointAt(0) <= 0x7e);\n"
"async function userOperation(operation, extra = {}, target = selectedUser()) {\n"
" if (!usersState) throw new Error('User list is not loaded.');\n"
" const fields = {operation, expected_generation: String(usersState.generation), ...extra};\n"
" if (operation !== 'create') {\n"
" if (!target) throw new Error('Select an account first.');\n"
" fields.expected_user_id = String(target.id);\n"
" fields.username = target.username;\n"
" }\n"
" const clearSubmittedValues = () => {\n"
" if ('password' in fields) fields.password = '';\n"
" if ('password' in extra) extra.password = '';\n"
" userPassword.value = '';\n"
" userCreatePassword.value = '';\n"
" userKey.value = '';\n"
" };\n"
" settingsMessage.textContent = `User ${operation} in progress…`;\n"
" let result;\n"
" try {\n"
" result = await adminRequest('/api/admin/users', 'POST', fields);\n"
" } catch (error) {\n"
" clearSubmittedValues();\n"
" clearGeneratedPassword();\n"
" if (error.code === 'stale') {\n"
" try {\n"
" await loadUsers(target?.id ?? null);\n"
" } catch (_) {\n"
" usersState = null;\n"
" userSelect.replaceChildren();\n"
" renderSelectedUser();\n"
" throw new Error('User database changed and could not be reloaded. Close Settings and try again.');\n"
" }\n"
" throw new Error('User database changed. The account form was reloaded; re-enter any password and try again.');\n"
" }\n"
" throw error;\n"
" } finally {\n"
" clearSubmittedValues();\n"
" }\n"
" if (typeof result?.generated_password === 'string' && result.generated_password.length > 0) {\n"
" showGeneratedPassword(result.generated_password);\n"
" result.generated_password = '';\n"
" }\n"
" const preferred = validId(result?.user_id) ? result.user_id : target?.id ?? null;\n"
" await loadUsers(preferred);\n"
" settingsMessage.textContent = result?.revocation_complete === false\n"
" ? `User ${operation} committed; a transport session will close on its next authorization check.`\n"
" : `User ${operation} completed.`;\n"
"}\n"
"const bytesToBase64 = (bytes) => {\n"
" let binary = '';\n"
" for (const byte of bytes) binary += String.fromCharCode(byte);\n"
" return btoa(binary);\n"
"};\n"
"const ssidToBase64 = (value) => {\n"
" const bytes = encoder.encode(value);\n"
" if (bytes.length < 1 || bytes.length > 32) throw new Error('SSID must encode to 132 bytes.');\n"
" return bytesToBase64(bytes);\n"
"};\n"
"const ssidFromBase64 = (value) => {\n"
" const binary = atob(value);\n"
" const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));\n"
" return new TextDecoder().decode(bytes);\n"
"};\n"
"const ssidFieldValue = (input) => input.value === input.dataset.originalValue\n"
" ? input.dataset.originalBase64\n"
" : ssidToBase64(input.value);\n"
"const selectedWifiProfile = () => {\n"
" if (!wifiConfigState || !Array.isArray(wifiConfigState.profiles)) return null;\n"
" return wifiConfigState.profiles.find((profile) => String(profile.slot) === wifiProfileSlot.value) || null;\n"
"};\n"
"const fillWifiProfile = () => {\n"
" const profile = selectedWifiProfile();\n"
" if (!profile) return;\n"
" const ssid = ssidFromBase64(profile.ssid_b64);\n"
" wifiProfileSsid.value = ssid;\n"
" wifiProfileSsid.dataset.originalValue = ssid;\n"
" wifiProfileSsid.dataset.originalBase64 = profile.ssid_b64;\n"
" wifiProfilePriority.value = String(profile.priority);\n"
" wifiProfileSecurity.value = profile.security;\n"
" wifiProfileStatus.textContent = `${profile.configured ? 'configured' : 'empty'} · ${profile.enabled ? 'enabled' : 'disabled'}`;\n"
" wifiProfileSecretState.textContent = profile.secret_set ? 'A secret is set.' : 'No secret is set.';\n"
" wifiProfileToggle.textContent = profile.enabled ? 'Disable' : 'Enable';\n"
" wifiProfileToggle.disabled = !profile.enabled && !(profile.configured && profile.secret_set);\n"
" element('wifi-profile-secret-set').disabled = !profile.configured;\n"
" wifiProfileSecret.value = '';\n"
"};\n"
"const fillWifiAp = () => {\n"
" if (!wifiConfigState?.ap) return;\n"
" const ssid = ssidFromBase64(wifiConfigState.ap.ssid_b64);\n"
" wifiApSsid.value = ssid;\n"
" wifiApSsid.dataset.originalValue = ssid;\n"
" wifiApSsid.dataset.originalBase64 = wifiConfigState.ap.ssid_b64;\n"
" wifiApPolicy.value = wifiConfigState.ap.policy;\n"
" wifiApChannel.value = String(wifiConfigState.ap.channel);\n"
" wifiApSecretState.textContent = wifiConfigState.ap.secret_set ? 'A secret is set.' : 'No secret is set.';\n"
" wifiApSecret.value = '';\n"
"};\n"
"async function loadWifiConfig() {\n"
" const payload = await adminRequest('/api/admin/wifi-config');\n"
" if (!payload || !Number.isSafeInteger(payload.generation) || payload.generation < 1 ||\n"
" !payload.ap || !Array.isArray(payload.profiles) || payload.profiles.length !== 4) {\n"
" throw new Error('invalid Wi-Fi configuration response');\n"
" }\n"
" wifiConfigState = payload;\n"
" fillWifiProfile();\n"
" fillWifiAp();\n"
" return payload;\n"
"}\n"
"async function wifiConfigOperation(operation, extra = {}) {\n"
" if (!wifiConfigState) throw new Error('Wi-Fi configuration is not loaded.');\n"
" const fields = {operation, expected_generation: String(wifiConfigState.generation), ...extra};\n"
" settingsMessage.textContent = `Wi-Fi ${operation} in progress…`;\n"
" try {\n"
" await adminRequest('/api/admin/wifi-config', 'POST', fields);\n"
" wifiProfileSecret.value = '';\n"
" wifiApSecret.value = '';\n"
" await loadWifiConfig();\n"
" settingsMessage.textContent = `Wi-Fi ${operation} completed.`;\n"
" window.setTimeout(pollStatus, 300);\n"
" } catch (error) {\n"
" wifiProfileSecret.value = '';\n"
" wifiApSecret.value = '';\n"
" if (error.code === 'stale') {\n"
" try { await loadWifiConfig(); } catch (_) {}\n"
" throw new Error('Wi-Fi configuration changed. The form was reloaded; re-enter any secret and try again.');\n"
" }\n"
" throw error;\n"
" }\n"
"}\n"
"const fillSerialSettings = (serial) => {\n"
" serialBaud.value = String(serial.baud); serialData.value = serial.data;\n"
" serialParity.value = serial.parity; serialStopBits.value = serial.stop;\n"
" serialFlow.value = serial.flow; serialDtr.value = serial.dtr; serialRts.value = String(serial.rts);\n"
"};\n"
"async function loadSerialSettings() {\n"
" const serial = await adminRequest('/api/admin/serial');\n"
" fillSerialSettings(serial);\n"
" return serial;\n"
"}\n"
"const fillDisplaySettings = (display) => {\n"
" displayDim.value = String(display.dim_seconds);\n"
" displayOff.value = String(display.off_seconds);\n"
" displayState.textContent = display.service_available\n"
" ? `${display.initialized ? 'OLED initialized' : 'OLED unavailable'} · bus ${display.bus_ready ? 'ready' : 'unavailable'} · address 0x${Number(display.address).toString(16).padStart(2, '0')} · contrast ${display.contrast}`\n"
" : 'Local OLED status unavailable.';\n"
"};\n"
"async function loadDisplaySettings() {\n"
" const display = await adminRequest('/api/admin/display');\n"
" fillDisplaySettings(display);\n"
" return display;\n"
"}\n"
"async function displayOperation(operation) {\n"
" const fields = {operation};\n"
" if (operation === 'apply') {\n"
" if (!displayForm.reportValidity()) throw new Error('Enter display timeouts from 0 to 86400 seconds.');\n"
" const dim = Number(displayDim.value);\n"
" const off = Number(displayOff.value);\n"
" if (!Number.isInteger(dim) || !Number.isInteger(off) || dim < 0 || dim > 86400 || off < 0 || off > 86400) {\n"
" throw new Error('Display timeouts must be whole numbers from 0 to 86400.');\n"
" }\n"
" if (dim !== 0 && off !== 0 && off <= dim) throw new Error('Display off time must be later than dim time when both are enabled.');\n"
" fields.dim_seconds = String(dim);\n"
" fields.off_seconds = String(off);\n"
" }\n"
" if (operation === 'reset') {\n"
" if (!window.confirm('Reset display aging settings to defaults and persist them?')) return;\n"
" fields.confirm = 'true';\n"
" }\n"
" settingsMessage.textContent = `Display ${operation} in progress…`;\n"
" await adminRequest('/api/admin/display', 'POST', fields);\n"
" await loadDisplaySettings();\n"
" settingsMessage.textContent = `Display ${operation} completed.`;\n"
"}\n"
"async function serialOperation(operation) {\n"
" if (operation === 'reset') {\n"
" if (!window.confirm('Reset serial settings to defaults and persist them?')) return;\n"
" }\n"
" if (['apply','load','defaults','reset','stop'].includes(operation)) {\n"
" const current = await adminRequest('/api/admin/serial');\n"
" if ((current.rx_pending > 0 || current.tx_pending > 0) &&\n"
" !window.confirm(`Serial has ${current.rx_pending} RX and ${current.tx_pending} TX bytes pending. Continue with ${operation}?`)) throw new Error('Serial operation cancelled.');\n"
" }\n"
" const fields = {operation};\n"
" if (operation === 'apply') Object.assign(fields, {\n"
" baud: serialBaud.value, data: serialData.value, parity: serialParity.value,\n"
" stop: serialStopBits.value, flow: serialFlow.value, dtr: serialDtr.value, rts: serialRts.value\n"
" });\n"
" if (operation === 'reset') fields.confirm = 'true';\n"
" settingsMessage.textContent = `Serial ${operation} in progress…`;\n"
" await adminRequest('/api/admin/serial', 'POST', fields);\n"
" await loadSerialSettings();\n"
" settingsMessage.textContent = `Serial ${operation} completed.`;\n"
" pollStatus();\n"
"}\n"
"async function wifiOperation(operation) {\n"
" settingsMessage.textContent = `Wi-Fi ${operation} requested…`;\n"
" await adminRequest('/api/admin/wifi', 'POST', {operation});\n"
" settingsMessage.textContent = `Wi-Fi ${operation} queued.`;\n"
" window.setTimeout(pollStatus, 300);\n"
"}\n"
"const makeButton = (label, action, primary = false) => {\n"
" const button = document.createElement('button');\n"
" button.type = 'button'; button.className = primary ? 'button primary' : 'button';\n"
" button.textContent = label; button.addEventListener('click', action); return button;\n"
"};\n"
"const positionPopover = (trigger) => {\n"
" const rect = trigger.getBoundingClientRect();\n"
" const width = Math.min(520, window.innerWidth * .92);\n"
" quickPopover.style.left = `${Math.max(12, Math.min(rect.left, window.innerWidth - width - 12))}px`;\n"
" const below = rect.bottom + 8;\n"
" quickPopover.style.top = `${below + Math.min(420, window.innerHeight * .72) < window.innerHeight ? below : Math.max(12, rect.top - Math.min(420, window.innerHeight * .72) - 8)}px`;\n"
"};\n"
"const closePopover = (restoreFocus = false) => {\n"
" ++popoverGeneration; quickPopover.hidden = true; popoverContent.replaceChildren();\n"
" const previous = popoverTrigger; popoverTrigger = null;\n"
" if (restoreFocus && previous !== null) previous.focus();\n"
"};\n"
"const clientRow = (client, action = null) => {\n"
" const row = document.createElement('div'); row.className = 'client-row';\n"
" const copy = document.createElement('div');\n"
" const title = document.createElement('strong'); title.textContent = `${client.name || client.type} · ID ${client.id}`;\n"
" const meta = document.createElement('div'); meta.className = 'client-meta';\n"
" meta.textContent = `${client.type} · ${client.writer ? 'writer' : 'observer'} · ${client.output_pending}/4096 queued · ${client.output_dropped} dropped`;\n"
" copy.append(title, meta); row.append(copy); if (action !== null) row.append(action); return row;\n"
"};\n"
"async function renderPopover(kind, generation) {\n"
" if (kind === 'serial') {\n"
" const serial = await adminRequest('/api/admin/serial'); if (generation !== popoverGeneration) return;\n"
" const summary = document.createElement('p'); summary.className = 'client-meta';\n"
" summary.textContent = `${serial.running ? 'Running' : 'Stopped'} · ${serial.baud} baud · ${serial.data}${serial.parity[0].toUpperCase()}${serial.stop} · ${serial.flow} · DTR ${serial.dtr}`;\n"
" const actions = document.createElement('div'); actions.className = 'quick-actions';\n"
" actions.append(makeButton(serial.running ? 'Stop' : 'Start', async () => { try { await serialOperation(serial.running ? 'stop' : 'start'); closePopover(); } catch (error) { settingsMessage.textContent = error.message; } }),\n"
" makeButton('Save', async () => { try { await serialOperation('save'); closePopover(); } catch (error) { settingsMessage.textContent = error.message; } }),\n"
" makeButton('Open settings', () => { closePopover(); openSettings(); }, true));\n"
" popoverContent.replaceChildren(summary, actions); return;\n"
" }\n"
" if (kind === 'wifi') {\n"
" const summary = document.createElement('p'); summary.className = 'client-meta'; summary.textContent = wifiSummary.textContent;\n"
" const actions = document.createElement('div'); actions.className = 'quick-actions';\n"
" for (const [label, operation] of [['Reconnect','reconnect'],['Next profile','next-profile'],['Start','start'],['Stop','stop']]) {\n"
" actions.append(makeButton(label, async () => { try { await wifiOperation(operation); closePopover(); } catch (error) { settingsMessage.textContent = error.message; } }, operation === 'reconnect'));\n"
" }\n"
" actions.append(makeButton('Open settings', () => { closePopover(); openSettings(); }));\n"
" popoverContent.replaceChildren(summary, actions); return;\n"
" }\n"
" const payload = await adminRequest('/api/admin/broker-clients'); if (generation !== popoverGeneration) return;\n"
" const list = document.createElement('div'); list.className = 'popover-list';\n"
" if (payload.clients.length === 0) { const empty = document.createElement('p'); empty.className = 'client-meta'; empty.textContent = 'No broker clients are connected.'; list.append(empty); }\n"
" for (const client of payload.clients) {\n"
" let action = null;\n"
" if (kind === 'writer' && !client.writer) action = makeButton('Assign', async () => {\n"
" if (!window.confirm(`Transfer the writer lease to ${client.name || client.type} (ID ${client.id})?`)) return;\n"
" try { await adminRequest('/api/admin/broker-writer', 'POST', {expected: String(payload.writer), target: String(client.id), confirm: 'true'}); await openPopover('writer', popoverTrigger); pollStatus(); }\n"
" catch (error) { const message = document.createElement('p'); message.className = 'session-notice'; message.dataset.visible = 'true'; message.textContent = error.message; popoverContent.prepend(message); }\n"
" }, true);\n"
" list.append(clientRow(client, action));\n"
" }\n"
" popoverContent.replaceChildren(list);\n"
"}\n"
"async function openPopover(kind, trigger) {\n"
" if (accountRole !== 'admin' || trigger === null) return;\n"
" popoverTrigger = trigger; const generation = ++popoverGeneration;\n"
" popoverTitle.textContent = kind === 'serial' ? 'Serial quick settings' : kind === 'wifi' ? 'Wi-Fi quick settings' : kind === 'writer' ? 'Transfer writer lease' : 'Broker clients';\n"
" const loading = document.createElement('p'); loading.className = 'client-meta'; loading.textContent = 'Loading…';\n"
" popoverContent.replaceChildren(loading); quickPopover.hidden = false; positionPopover(trigger);\n"
" try { await renderPopover(kind, generation); }\n"
" catch (error) { if (generation === popoverGeneration) { loading.textContent = error.message; popoverContent.replaceChildren(loading); } }\n"
"}\n"
"const closeSettings = (restoreFocus = true) => {\n"
" settingsBackdrop.hidden = true;\n"
" userCreatePassword.value = ''; userPassword.value = ''; userKey.value = '';\n"
" wifiProfileSecret.value = ''; wifiApSecret.value = '';\n"
" clearGeneratedPassword();\n"
" if (restoreFocus) settingsOpen.focus();\n"
"};\n"
"async function openSettings() {\n"
" if (accountRole !== 'admin') return;\n"
" closePopover();\n"
" settingsMessage.textContent = 'Loading settings…';\n"
" settingsBackdrop.hidden = false;\n"
" try {\n"
" await loadSerialSettings();\n"
" await loadUsers();\n"
" await loadWifiConfig();\n"
" await loadDisplaySettings();\n"
" settingsMessage.textContent = '';\n"
" } catch (error) {\n"
" settingsMessage.textContent = error.message;\n"
" }\n"
" settingsClose.focus();\n"
"}\n"
"serialTerminal.onData((data) => {\n"
" if (brokerRole !== 'writer' || !socketOpen()) return;\n"
" const bytes = encoder.encode(data);\n"
" for (let offset = 0; offset < bytes.length; offset += 1024) {\n"
" socket.send(bytes.subarray(offset, Math.min(offset + 1024, bytes.length)));\n"
" }\n"
"});\n"
"adminTerminal.onData((data) => {\n"
" if (!adminSocketOpen()) return;\n"
" const bytes = encoder.encode(data);\n"
" for (let offset = 0; offset < bytes.length; offset += 1024) {\n"
" adminSocket.send(bytes.subarray(offset, Math.min(offset + 1024, bytes.length)));\n"
" }\n"
"});\n"
"serialModeButton.addEventListener('click', () => setTerminalMode('serial'));\n"
"adminModeButton.addEventListener('click', () => setTerminalMode('admin'));\n"
"for (const [card, kind] of [[serialCard,'serial'],[wifiCard,'wifi'],[clientsCard,'clients'],[writerCard,'writer']]) {\n"
" card.addEventListener('pointerenter', () => openPopover(kind, card));\n"
" card.addEventListener('focus', () => openPopover(kind, card));\n"
" card.addEventListener('click', () => openPopover(kind, card));\n"
" card.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openPopover(kind, card); } });\n"
"}\n"
"popoverClose.addEventListener('click', () => closePopover(true));\n"
"document.addEventListener('pointerdown', (event) => {\n"
" if (!quickPopover.hidden && !quickPopover.contains(event.target) && event.target !== popoverTrigger && !popoverTrigger?.contains(event.target)) closePopover();\n"
"});\n"
"document.addEventListener('keydown', (event) => {\n"
" if (event.key !== 'Escape') return;\n"
" if (!quickPopover.hidden) closePopover(true);\n"
" else if (!settingsBackdrop.hidden) closeSettings();\n"
"});\n"
"settingsOpen.addEventListener('click', openSettings);\n"
"settingsClose.addEventListener('click', () => closeSettings());\n"
"settingsBackdrop.addEventListener('pointerdown', (event) => { if (event.target === settingsBackdrop) closeSettings(); });\n"
"settingsAdminShell.addEventListener('click', () => { closeSettings(false); setTerminalMode('admin'); });\n"
"serialSettingsForm.addEventListener('submit', (event) => event.preventDefault());\n"
"const runSettingsAction = async (action, secretScope = null) => {\n"
" try { await action(); }\n"
" catch (error) { if (secretScope === 'user') clearGeneratedPassword(); settingsMessage.textContent = error.message; }\n"
" finally {\n"
" if (secretScope === 'user') { userPassword.value = ''; userCreatePassword.value = ''; userKey.value = ''; }\n"
" if (secretScope === 'wifi') { wifiProfileSecret.value = ''; wifiApSecret.value = ''; }\n"
" }\n"
"};\n"
"const updateCreatePasswordMode = () => {\n"
" const generated = userCreatePasswordMode.value === 'generated';\n"
" userCreatePassword.disabled = generated;\n"
" userCreatePassword.required = !generated;\n"
" if (generated) userCreatePassword.value = '';\n"
"};\n"
"userCreatePasswordMode.addEventListener('change', updateCreatePasswordMode);\n"
"updateCreatePasswordMode();\n"
"userSelect.addEventListener('change', renderSelectedUser);\n"
"userCreateForm.addEventListener('submit', (event) => event.preventDefault());\n"
"userEditForm.addEventListener('submit', (event) => event.preventDefault());\n"
"element('user-create').addEventListener('click', () => runSettingsAction(async () => {\n"
" updateCreatePasswordMode();\n"
" if (!userCreateForm.reportValidity()) return;\n"
" const generated = userCreatePasswordMode.value === 'generated';\n"
" if (!generated && !printableAscii(userCreatePassword.value, 12, 64)) throw new Error('Password must be 1264 printable ASCII characters.');\n"
" await userOperation('create', {\n"
" username: userCreateName.value,\n"
" role: userCreateRole.value,\n"
" password_mode: generated ? 'generated' : 'provided',\n"
" ...(generated ? {} : {password: userCreatePassword.value})\n"
" }, null);\n"
" userCreateName.value = '';\n"
"}, 'user'));\n"
"element('user-role-apply').addEventListener('click', () => runSettingsAction(async () => {\n"
" const user = selectedUser(); if (!user) return;\n"
" if (!window.confirm(`Change ${user.username} from ${user.role} to ${userRole.value}?`)) return;\n"
" await userOperation('set-role', {role: userRole.value, confirm: 'true'}, user);\n"
"}, 'user'));\n"
"element('user-password-set').addEventListener('click', () => runSettingsAction(async () => {\n"
" const user = selectedUser(); if (!user) return;\n"
" if (!userPassword.reportValidity() || !printableAscii(userPassword.value, 12, 64)) throw new Error('Password must be 1264 printable ASCII characters.');\n"
" if (!window.confirm(`Replace the password for ${user.username}? Active sessions for that account will be revoked.`)) return;\n"
" await userOperation('set-password', {password_mode: 'provided', password: userPassword.value}, user);\n"
"}, 'user'));\n"
"element('user-password-generate').addEventListener('click', () => runSettingsAction(async () => {\n"
" const user = selectedUser(); if (!user) return;\n"
" if (user.username === accountUsername) throw new Error('Generating a replacement password for the current administrator is disabled. Set one explicitly instead.');\n"
" if (!window.confirm(`Generate and install a new password for ${user.username}? It will be shown only once.`)) return;\n"
" await userOperation('set-password', {password_mode: 'generated'}, user);\n"
"}, 'user'));\n"
"element('user-key-add').addEventListener('click', () => runSettingsAction(async () => {\n"
" const user = selectedUser(); if (!user) return;\n"
" const parts = userKey.value.trim().split(/\\s+/);\n"
" if (parts.length < 2 || !['ssh-ed25519','ecdsa-sha2-nistp256'].includes(parts[0]) || !/^[A-Za-z0-9+/]+={0,2}$/.test(parts[1])) {\n"
" throw new Error('Enter an OpenSSH Ed25519 or ECDSA P-256 public key.');\n"
" }\n"
" await userOperation('key-add', {key_type: parts[0], key_base64: parts[1]}, user);\n"
"}, 'user'));\n"
"element('user-key-clear').addEventListener('click', () => runSettingsAction(async () => {\n"
" const user = selectedUser(); if (!user || !Array.isArray(user.keys) || user.keys.length === 0) return;\n"
" if (!window.confirm(`Remove all SSH public keys from ${user.username}?`)) return;\n"
" await userOperation('key-clear', {confirm: 'true'}, user);\n"
"}, 'user'));\n"
"element('user-delete').addEventListener('click', () => runSettingsAction(async () => {\n"
" const user = selectedUser(); if (!user) return;\n"
" if (!window.confirm(`Permanently delete account ${user.username}?`)) return;\n"
" await userOperation('delete', {confirm: 'true'}, user);\n"
"}, 'user'));\n"
"element('generated-password-clear').addEventListener('click', clearGeneratedPassword);\n"
"element('generated-password-copy').addEventListener('click', () => runSettingsAction(async () => {\n"
" if (generatedPassword.textContent.length === 0) return;\n"
" await navigator.clipboard.writeText(generatedPassword.textContent);\n"
" settingsMessage.textContent = 'Generated password copied. Clear it after storing it safely.';\n"
"}));\n"
"wifiProfileForm.addEventListener('submit', (event) => event.preventDefault());\n"
"wifiApForm.addEventListener('submit', (event) => event.preventDefault());\n"
"wifiProfileSlot.addEventListener('change', fillWifiProfile);\n"
"element('wifi-profile-apply').addEventListener('click', () => runSettingsAction(() => wifiConfigOperation('profile-apply', {\n"
" slot: wifiProfileSlot.value,\n"
" ssid_b64: ssidFieldValue(wifiProfileSsid),\n"
" priority: wifiProfilePriority.value,\n"
" security: wifiProfileSecurity.value\n"
"}), 'wifi'));\n"
"wifiProfileToggle.addEventListener('click', () => runSettingsAction(() => {\n"
" const profile = selectedWifiProfile(); if (!profile) throw new Error('Select a profile first.');\n"
" return wifiConfigOperation(profile.enabled ? 'profile-disable' : 'profile-enable', {slot: String(profile.slot)});\n"
"}, 'wifi'));\n"
"element('wifi-profile-secret-set').addEventListener('click', () => runSettingsAction(() => {\n"
" const profile = selectedWifiProfile(); if (!profile) throw new Error('Select a profile first.');\n"
" if (!wifiProfileSecret.reportValidity() || !printableAscii(wifiProfileSecret.value, 8, 63)) throw new Error('Wi-Fi secret must be 863 printable ASCII characters.');\n"
" return wifiConfigOperation('profile-secret', {slot: String(profile.slot), secret: wifiProfileSecret.value});\n"
"}, 'wifi'));\n"
"element('wifi-profile-delete').addEventListener('click', () => runSettingsAction(() => {\n"
" const profile = selectedWifiProfile(); if (!profile) throw new Error('Select a profile first.');\n"
" if (!window.confirm(`Delete Wi-Fi profile slot ${profile.slot}, including its stored secret?`)) return Promise.resolve();\n"
" return wifiConfigOperation('profile-delete', {slot: String(profile.slot), confirm: 'true'});\n"
"}, 'wifi'));\n"
"element('wifi-ap-apply').addEventListener('click', () => runSettingsAction(() => wifiConfigOperation('ap-apply', {\n"
" policy: wifiApPolicy.value,\n"
" ssid_b64: ssidFieldValue(wifiApSsid),\n"
" channel: wifiApChannel.value\n"
"}), 'wifi'));\n"
"element('wifi-ap-secret-set').addEventListener('click', () => runSettingsAction(() => {\n"
" if (!wifiApSecret.reportValidity() || !printableAscii(wifiApSecret.value, 8, 63)) throw new Error('AP secret must be 863 printable ASCII characters.');\n"
" return wifiConfigOperation('ap-secret', {secret: wifiApSecret.value});\n"
"}, 'wifi'));\n"
"element('wifi-config-save').addEventListener('click', () => runSettingsAction(() => wifiConfigOperation('save'), 'wifi'));\n"
"displayForm.addEventListener('submit', (event) => event.preventDefault());\n"
"element('display-apply').addEventListener('click', () => runSettingsAction(() => displayOperation('apply')));\n"
"for (const operation of ['save','load','defaults','reset']) element(`display-${operation}`).addEventListener('click', () => runSettingsAction(() => displayOperation(operation)));\n"
"element('serial-apply').addEventListener('click', () => runSettingsAction(() => serialOperation('apply')));\n"
"for (const operation of ['save','start','stop','load','defaults','reset']) element(`serial-${operation}`).addEventListener('click', () => runSettingsAction(() => serialOperation(operation)));\n"
"for (const button of document.querySelectorAll('[data-wifi-action]')) button.addEventListener('click', () => runSettingsAction(() => wifiOperation(button.dataset.wifiAction)));\n"
"requestControl.addEventListener('click', () => {\n"
" if (brokerRole !== 'writer' && socketOpen()) socket.send('request-writer');\n"
"});\n"
"releaseControl.addEventListener('click', () => {\n"
" if (brokerRole === 'writer' && socketOpen()) socket.send('release-writer');\n"
"});\n"
"logoutButton.addEventListener('click', async () => {\n"
" if (unloading || authRedirecting || !sessionReady || typeof csrfToken !== 'string') return;\n"
" const token = csrfToken;\n"
" setSessionNotice('');\n"
" authRedirecting = true;\n"
" stopAuthenticatedActivity();\n"
" try {\n"
" const response = await fetch('/logout', {\n"
" method: 'POST', credentials: 'same-origin', cache: 'no-store',\n"
" headers: {'X-CSRF-Token': token}\n"
" });\n"
" if (!response.ok) throw new Error('logout request failed');\n"
" csrfToken = null;\n"
" accountUsername = null;\n"
" accountRole = null;\n"
" window.location.assign('/login');\n"
" } catch (_) {\n"
" authRedirecting = false;\n"
" csrfToken = null;\n"
" accountUsername = null;\n"
" accountRole = null;\n"
" accountUsernameField.textContent = 'Restoring…';\n"
" accountRoleField.textContent = 'Session';\n"
" setSessionNotice('Logout failed; session remains active.');\n"
" startAuthenticatedUi();\n"
" }\n"
"});\n"
"connectionToggle.addEventListener('click', () => {\n"
" const connectionActive = reconnectEnabled || socket !== null || ticketAbort !== null || reconnectTimer !== null;\n"
" if (!connectionActive) {\n"
" reconnectEnabled = true;\n"
" reconnectDelay = 1000;\n"
" if (!sessionReady) { startAuthenticatedUi(); return; }\n"
" connect();\n"
" return;\n"
" }\n"
" reconnectEnabled = false;\n"
" ++connectionGeneration;\n"
" clearReconnectTimer();\n"
" if (ticketAbort !== null) { ticketAbort.abort(); ticketAbort = null; }\n"
" if (socket !== null) { const previous = socket; socket = null; previous.close(); }\n"
" clientId = null;\n"
" clientIdField.textContent = '—';\n"
" setBrokerRole('observer');\n"
" setConnection('Disconnected', 'warn', 'Disconnected by user. Automatic reconnect is paused.');\n"
"});\n"
"const fitTerminal = () => {\n"
" fitFrame = 0;\n"
" const admin = terminalMode === 'admin';\n"
" const host = admin ? adminTerminalHost : serialTerminalHost;\n"
" const instance = admin ? adminTerminal : serialTerminal;\n"
" const addon = admin ? adminFitAddon : serialFitAddon;\n"
" const bounds = host.getBoundingClientRect();\n"
" const width = Math.floor(bounds.width);\n"
" const height = Math.floor(bounds.height);\n"
" const unchanged = admin\n"
" ? width === adminLastFitWidth && height === adminLastFitHeight\n"
" : width === serialLastFitWidth && height === serialLastFitHeight;\n"
" if (width < 1 || height < 1 || unchanged) return;\n"
" if (admin) { adminLastFitWidth = width; adminLastFitHeight = height; }\n"
" else { serialLastFitWidth = width; serialLastFitHeight = height; }\n"
" try {\n"
" const dimensions = addon.proposeDimensions();\n"
" if (dimensions && dimensions.cols > 0 && dimensions.rows > 0 &&\n"
" (dimensions.cols !== instance.cols || dimensions.rows !== instance.rows)) {\n"
" instance.resize(dimensions.cols, dimensions.rows);\n"
" }\n"
" } catch (_) {}\n"
"};\n"
"const scheduleFit = () => {\n"
" if (fitFrame === 0) fitFrame = window.requestAnimationFrame(fitTerminal);\n"
"};\n"
"const resizeObserver = 'ResizeObserver' in window ? new ResizeObserver(scheduleFit) : null;\n"
"if (resizeObserver !== null) { resizeObserver.observe(serialTerminalHost); resizeObserver.observe(adminTerminalHost); }\n"
"window.addEventListener('resize', scheduleFit);\n"
"const textValue = (value, fallback) => typeof value === 'string' && value.length > 0 ? value : fallback;\n"
"const updateStatus = (status) => {\n"
" const wifi = status !== null && typeof status === 'object' ? status.wifi : null;\n"
" if (wifi && wifi.available) {\n"
" const parts = [textValue(wifi.state, 'unknown')];\n"
" if (typeof wifi.sta_ipv4 === 'string' && wifi.sta_ipv4 !== '0.0.0.0') parts.push(wifi.sta_ipv4);\n"
" if (Number.isFinite(wifi.rssi)) parts.push(`${wifi.rssi} dBm`);\n"
" if (Number.isFinite(wifi.channel) && wifi.channel > 0) parts.push(`channel ${wifi.channel}`);\n"
" if (wifi.ap_running && Number.isFinite(wifi.ap_clients)) parts.push(`AP clients ${wifi.ap_clients}`);\n"
" wifiSummary.textContent = parts.join(' · ');\n"
" } else {\n"
" wifiSummary.textContent = 'Unavailable';\n"
" }\n"
" const serial = status !== null && typeof status === 'object' ? status.serial : null;\n"
" if (serial && typeof serial === 'object') {\n"
" const parts = [serial.running ? 'Running' : 'Stopped'];\n"
" if (serial.config_available) {\n"
" if (Number.isFinite(serial.baud)) parts.push(`${serial.baud} baud`);\n"
" parts.push(`${textValue(serial.data_bits, '?')} data`);\n"
" parts.push(`${textValue(serial.parity, '?')} parity`);\n"
" parts.push(`${textValue(serial.stop_bits, '?')} stop`);\n"
" parts.push(`${textValue(serial.flow, '?')} flow`);\n"
" }\n"
" serialSummary.textContent = parts.join(' · ');\n"
" } else {\n"
" serialSummary.textContent = 'Unavailable';\n"
" }\n"
" const broker = status !== null && typeof status === 'object' ? status.broker : null;\n"
" if (broker && broker.available) {\n"
" brokerClientsField.textContent = validId(broker.clients) ? String(broker.clients) : '—';\n"
" if (validId(broker.writer)) {\n"
" writerId = broker.writer;\n"
" writerIdField.textContent = displayId(writerId, 'None');\n"
" }\n"
" } else {\n"
" brokerClientsField.textContent = '—';\n"
" }\n"
"};\n"
"async function pollStatus() {\n"
" if (unloading || statusInFlight) return;\n"
" statusInFlight = true;\n"
" try {\n"
" const response = await fetch('/api/status', {credentials: 'same-origin', cache: 'no-store'});\n"
" if (response.status === 401) { navigateToLogin(); return; }\n"
" if (!response.ok) throw new Error('status request failed');\n"
" updateStatus(await response.json());\n"
" } catch (_) {\n"
" wifiSummary.textContent = 'Unavailable';\n"
" serialSummary.textContent = 'Unavailable';\n"
" brokerClientsField.textContent = '—';\n"
" } finally {\n"
" statusInFlight = false;\n"
" }\n"
"}\n"
"async function loadSession() {\n"
" const controller = new AbortController();\n"
" sessionAbort = controller;\n"
" try {\n"
" const response = await fetch('/api/session', {\n"
" method: 'GET', credentials: 'same-origin', cache: 'no-store', signal: controller.signal\n"
" });\n"
" if (response.status === 401) {\n"
" navigateToLogin();\n"
" throw new Error('session expired');\n"
" }\n"
" if (!response.ok) throw new Error('session request failed');\n"
" const payload = await response.json();\n"
" if (payload === null || typeof payload !== 'object' ||\n"
" typeof payload.username !== 'string' || payload.username.length === 0 ||\n"
" (payload.role !== 'user' && payload.role !== 'admin') ||\n"
" typeof payload.csrf !== 'string' || payload.csrf.length === 0) {\n"
" throw new Error('invalid session response');\n"
" }\n"
" accountUsername = payload.username;\n"
" accountRole = payload.role;\n"
" csrfToken = payload.csrf;\n"
" payload.csrf = '';\n"
" accountUsernameField.textContent = accountUsername;\n"
" accountRoleField.textContent = accountRole === 'admin' ? 'Administrator' : 'User';\n"
" const administrator = accountRole === 'admin';\n"
" terminalModes.hidden = !administrator;\n"
" settingsOpen.hidden = !administrator;\n"
" for (const card of [serialCard,wifiCard,clientsCard,writerCard]) {\n"
" card.classList.toggle('interactive', administrator); card.tabIndex = administrator ? 0 : -1;\n"
" }\n"
" sessionReady = true;\n"
" if (accountRole !== 'admin') setTerminalMode('serial');\n"
" else if (terminalMode === 'admin') connectAdmin();\n"
" } finally {\n"
" if (sessionAbort === controller) sessionAbort = null;\n"
" }\n"
"}\n"
"async function startAuthenticatedUi() {\n"
" if (unloading || authRedirecting || startupInFlight || sessionReady) return;\n"
" startupInFlight = true;\n"
" reconnectEnabled = true;\n"
" setConnection('Connecting', 'warn', 'Loading account session…');\n"
" try {\n"
" await loadSession();\n"
" if (unloading || authRedirecting || !sessionReady) return;\n"
" scheduleFit();\n"
" pollStatus();\n"
" statusTimer = window.setInterval(pollStatus, 5000);\n"
" connect();\n"
" } catch (error) {\n"
" if (unloading || authRedirecting || error.name === 'AbortError') return;\n"
" csrfToken = null;\n"
" accountUsername = null;\n"
" accountRole = null;\n"
" sessionReady = false;\n"
" reconnectEnabled = false;\n"
" accountUsernameField.textContent = 'Unavailable';\n"
" accountRoleField.textContent = 'Session';\n"
" setConnection('Session unavailable', 'bad', 'Unable to load the account session. Select Connect to try again.');\n"
" } finally {\n"
" startupInFlight = false;\n"
" updateControls();\n"
" }\n"
"}\n"
"const shutdown = () => {\n"
" if (unloading) return;\n"
" unloading = true;\n"
" ++connectionGeneration;\n"
" ++adminConnectionGeneration;\n"
" clearReconnectTimer();\n"
" if (statusTimer !== null) window.clearInterval(statusTimer);\n"
" if (sessionAbort !== null) sessionAbort.abort();\n"
" if (fitFrame !== 0) window.cancelAnimationFrame(fitFrame);\n"
" if (resizeObserver !== null) resizeObserver.disconnect();\n"
" window.removeEventListener('resize', scheduleFit);\n"
" if (ticketAbort !== null) ticketAbort.abort();\n"
" if (adminTicketAbort !== null) adminTicketAbort.abort();\n"
" if (socket !== null) socket.close();\n"
" if (adminSocket !== null) adminSocket.close();\n"
" socket = null;\n"
" adminSocket = null;\n"
" csrfToken = null;\n"
" accountUsername = null;\n"
" accountRole = null;\n"
"};\n"
"window.addEventListener('pagehide', shutdown);\n"
"window.addEventListener('pageshow', (event) => {\n"
" if (!event.persisted) return;\n"
" unloading = false; authRedirecting = false; startupInFlight = false; sessionReady = false;\n"
" statusTimer = null; sessionAbort = null; ticketAbort = null; adminTicketAbort = null;\n"
" socket = null; adminSocket = null; reconnectEnabled = true;\n"
" if (resizeObserver !== null) { resizeObserver.observe(serialTerminalHost); resizeObserver.observe(adminTerminalHost); }\n"
" window.addEventListener('resize', scheduleFit); scheduleFit();\n"
" startAuthenticatedUi();\n"
"});\n"
"updateControls();\n"
"scheduleFit();\n"
"startAuthenticatedUi();\n"
"})();\n";
typedef enum {
WEB_UI_CSP_NONE = 0,
WEB_UI_CSP_APP,
WEB_UI_CSP_LOGIN,
} web_ui_csp_policy_t;
typedef struct {
const char *content_type;
const char *cache_control;
const char *content_encoding;
const uint8_t *data;
size_t length;
web_ui_csp_policy_t csp_policy;
} web_ui_response_t;
static esp_err_t describe_resource(web_ui_resource_t resource,
web_ui_response_t *response)
{
response->cache_control = WEB_UI_ASSET_CACHE_CONTROL;
response->content_encoding = NULL;
response->csp_policy = WEB_UI_CSP_NONE;
switch (resource) {
case WEB_UI_RESOURCE_INDEX:
response->content_type = "text/html; charset=utf-8";
response->cache_control = WEB_UI_DOCUMENT_CACHE_CONTROL;
response->data = (const uint8_t *)s_index_html;
response->length = sizeof(s_index_html) - 1U;
response->csp_policy = WEB_UI_CSP_APP;
return ESP_OK;
case WEB_UI_RESOURCE_LOGIN:
response->content_type = "text/html; charset=utf-8";
response->cache_control = WEB_UI_DOCUMENT_CACHE_CONTROL;
response->data = (const uint8_t *)s_login_html;
response->length = sizeof(s_login_html) - 1U;
response->csp_policy = WEB_UI_CSP_LOGIN;
return ESP_OK;
case WEB_UI_RESOURCE_LOGIN_ERROR:
response->content_type = "text/html; charset=utf-8";
response->cache_control = WEB_UI_DOCUMENT_CACHE_CONTROL;
response->data = (const uint8_t *)s_login_error_html;
response->length = sizeof(s_login_error_html) - 1U;
response->csp_policy = WEB_UI_CSP_LOGIN;
return ESP_OK;
case WEB_UI_RESOURCE_XTERM_JS:
response->content_type = "text/javascript; charset=utf-8";
response->content_encoding = "gzip";
response->data = web_asset_xterm_js_gz;
response->length = web_asset_xterm_js_gz_size;
return ESP_OK;
case WEB_UI_RESOURCE_XTERM_CSS:
response->content_type = "text/css; charset=utf-8";
response->content_encoding = "gzip";
response->data = web_asset_xterm_css_gz;
response->length = web_asset_xterm_css_gz_size;
return ESP_OK;
case WEB_UI_RESOURCE_ADDON_FIT_JS:
response->content_type = "text/javascript; charset=utf-8";
response->content_encoding = "gzip";
response->data = web_asset_addon_fit_js_gz;
response->length = web_asset_addon_fit_js_gz_size;
return ESP_OK;
case WEB_UI_RESOURCE_APP_JS:
response->content_type = "text/javascript; charset=utf-8";
response->cache_control = WEB_UI_AUTHORED_ASSET_CACHE_CONTROL;
response->data = (const uint8_t *)s_app_js;
response->length = sizeof(s_app_js) - 1U;
return ESP_OK;
case WEB_UI_RESOURCE_LOGO_PNG:
response->content_type = "image/png";
response->data = web_asset_logo_png;
response->length = web_asset_logo_png_size;
return ESP_OK;
default:
return ESP_ERR_INVALID_ARG;
}
}
static esp_err_t set_response_headers(httpd_req_t *request,
const web_ui_response_t *response)
{
esp_err_t result = httpd_resp_set_type(request, response->content_type);
if (result == ESP_OK) {
result = httpd_resp_set_hdr(request, "Cache-Control",
response->cache_control);
}
if (result == ESP_OK) {
result = httpd_resp_set_hdr(request, "X-Content-Type-Options",
"nosniff");
}
if (result == ESP_OK) {
result = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
}
if (result == ESP_OK && response->content_encoding != NULL) {
result = httpd_resp_set_hdr(request, "Content-Encoding",
response->content_encoding);
}
if (result == ESP_OK && response->csp_policy == WEB_UI_CSP_APP) {
result = httpd_resp_set_hdr(
request, "Content-Security-Policy",
"default-src 'none'; script-src 'self' 'sha256-5ukY3vEyRwFowsj4k3O4ilN8ezlXoZu1w90PPHg0YVE='; "
"script-src-elem 'self' 'sha256-5ukY3vEyRwFowsj4k3O4ilN8ezlXoZu1w90PPHg0YVE='; "
"style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; "
"base-uri 'none'; form-action 'none'; "
"frame-ancestors 'none'");
}
if (result == ESP_OK && response->csp_policy == WEB_UI_CSP_LOGIN) {
result = httpd_resp_set_hdr(
request, "Content-Security-Policy",
"default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; "
"img-src 'self' data:; base-uri 'none'; form-action 'self'; "
"frame-ancestors 'none'");
}
return result;
}
esp_err_t web_ui_send_response(httpd_req_t *request,
web_ui_resource_t resource)
{
if (request == NULL) {
return ESP_ERR_INVALID_ARG;
}
web_ui_response_t response = {0};
esp_err_t result = describe_resource(resource, &response);
if (result == ESP_OK) {
result = set_response_headers(request, &response);
}
if (result == ESP_OK) {
result = httpd_resp_send(request, (const char *)response.data,
(ssize_t)response.length);
}
return result;
}