Add authenticated WebSocket serial terminal - dirty commit with front-
and backend issues
This commit is contained in:
+511
@@ -0,0 +1,511 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Offline browser UI response helpers for 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 "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}\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"
|
||||
".page{min-height:100%;max-width:1440px;margin:auto;padding:clamp(14px,2.5vw,32px);"
|
||||
"display:grid;grid-template-rows:auto auto minmax(360px,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"
|
||||
".logo{width:42px;height:42px;border:1px solid #347ba5;border-radius:12px;"
|
||||
"display:grid;place-items:center;background:#10273a;color:var(--accent);"
|
||||
"font-weight:800;letter-spacing:.06em;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.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"
|
||||
".terminal-panel{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-title{color:var(--text);font-weight:750;letter-spacing:.02em}\n"
|
||||
"#terminal{flex:1;min-height:0;border-radius:9px;overflow:hidden;background:#080c12;padding:8px}\n"
|
||||
"#terminal .xterm{height:100%}\n"
|
||||
"#terminal .xterm-viewport{border-radius:7px}\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:480px){.page{padding:10px;gap:10px}.topbar{align-items:flex-start}"
|
||||
".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(420px,1fr)}}\n"
|
||||
"</style>\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\"><div class=\"logo\" aria-hidden=\"true\">SAK</div><div>"
|
||||
"<h1>ESP32 Serial Console</h1><p class=\"subtitle\">Secure local serial workspace</p></div></div>\n"
|
||||
"<span id=\"connection-status\" class=\"badge\" data-tone=\"warn\">Connecting</span>\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\">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 class=\"status-item\"><span class=\"label\">Active writer</span>"
|
||||
"<span id=\"writer-id\" class=\"value\">None</span></div>\n"
|
||||
"<div class=\"status-item\"><span class=\"label\">Broker clients</span>"
|
||||
"<span id=\"broker-clients\" class=\"value\">—</span></div>\n"
|
||||
"<div class=\"status-item wide\"><span class=\"label\">Wi-Fi</span>"
|
||||
"<span id=\"wifi-summary\" class=\"value\">Loading…</span></div>\n"
|
||||
"<div class=\"status-item wide\"><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=\"reconnect\" class=\"button\" type=\"button\">Reconnect</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"
|
||||
"</div>\n"
|
||||
"</section>\n"
|
||||
"<section class=\"panel terminal-panel\" aria-label=\"Serial terminal\">\n"
|
||||
"<div class=\"terminal-toolbar\"><span class=\"terminal-title\">Live serial stream</span>"
|
||||
"<span>Binary, unmodified device output</span></div>\n"
|
||||
"<div id=\"terminal\"></div>\n"
|
||||
"</section>\n"
|
||||
"</main>\n"
|
||||
"</body>\n"
|
||||
"</html>\n";
|
||||
|
||||
static const char s_app_js[] =
|
||||
"(() => {\n"
|
||||
"'use strict';\n"
|
||||
"const element = (id) => document.getElementById(id);\n"
|
||||
"const connectionStatus = element('connection-status');\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 wifiSummary = element('wifi-summary');\n"
|
||||
"const serialSummary = element('serial-summary');\n"
|
||||
"const inputState = element('input-state');\n"
|
||||
"const connectionDetail = element('connection-detail');\n"
|
||||
"const requestControl = element('request-control');\n"
|
||||
"const releaseControl = element('release-control');\n"
|
||||
"const reconnectButton = element('reconnect');\n"
|
||||
"const terminalHost = element('terminal');\n"
|
||||
"const terminal = new Terminal({\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 fitAddon = new FitAddon.FitAddon();\n"
|
||||
"terminal.loadAddon(fitAddon);\n"
|
||||
"terminal.open(terminalHost);\n"
|
||||
"const encoder = new TextEncoder();\n"
|
||||
"let socket = null;\n"
|
||||
"let ticketAbort = null;\n"
|
||||
"let reconnectTimer = null;\n"
|
||||
"let reconnectDelay = 1000;\n"
|
||||
"let connectionGeneration = 0;\n"
|
||||
"let role = 'observer';\n"
|
||||
"let clientId = null;\n"
|
||||
"let writerId = 0;\n"
|
||||
"let unloading = false;\n"
|
||||
"let fitFrame = 0;\n"
|
||||
"let statusInFlight = false;\n"
|
||||
"let statusTimer = 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 updateControls = () => {\n"
|
||||
" const writer = role === 'writer';\n"
|
||||
" terminal.options.disableStdin = !writer;\n"
|
||||
" requestControl.disabled = !socketOpen() || writer;\n"
|
||||
" releaseControl.disabled = !socketOpen() || !writer;\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 setRole = (nextRole) => {\n"
|
||||
" role = 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 clearReconnectTimer = () => {\n"
|
||||
" if (reconnectTimer !== null) {\n"
|
||||
" window.clearTimeout(reconnectTimer);\n"
|
||||
" reconnectTimer = null;\n"
|
||||
" }\n"
|
||||
"};\n"
|
||||
"const scheduleReconnect = () => {\n"
|
||||
" if (unloading || 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"
|
||||
" setRole(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"
|
||||
" terminal.write(new Uint8Array(event.data));\n"
|
||||
" }\n"
|
||||
"};\n"
|
||||
"async function requestTicket(signal) {\n"
|
||||
" const response = await fetch('/api/ws-ticket', {\n"
|
||||
" method: 'POST', credentials: 'same-origin', cache: 'no-store', signal\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) 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"
|
||||
" setRole('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"
|
||||
" setRole('observer');\n"
|
||||
" scheduleReconnect();\n"
|
||||
" });\n"
|
||||
" } catch (error) {\n"
|
||||
" if (generation !== connectionGeneration || unloading || error.name === 'AbortError') return;\n"
|
||||
" ticketAbort = null;\n"
|
||||
" scheduleReconnect();\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"terminal.onData((data) => {\n"
|
||||
" if (role !== '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"
|
||||
"requestControl.addEventListener('click', () => {\n"
|
||||
" if (role !== 'writer' && socketOpen()) socket.send('request-writer');\n"
|
||||
"});\n"
|
||||
"releaseControl.addEventListener('click', () => {\n"
|
||||
" if (role === 'writer' && socketOpen()) socket.send('release-writer');\n"
|
||||
"});\n"
|
||||
"reconnectButton.addEventListener('click', () => {\n"
|
||||
" reconnectDelay = 1000;\n"
|
||||
" connect();\n"
|
||||
"});\n"
|
||||
"const scheduleFit = () => {\n"
|
||||
" if (fitFrame !== 0) return;\n"
|
||||
" fitFrame = window.requestAnimationFrame(() => {\n"
|
||||
" fitFrame = 0;\n"
|
||||
" try { fitAddon.fit(); } catch (_) {}\n"
|
||||
" });\n"
|
||||
"};\n"
|
||||
"if ('ResizeObserver' in window) new ResizeObserver(scheduleFit).observe(terminalHost);\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.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"
|
||||
"const shutdown = () => {\n"
|
||||
" if (unloading) return;\n"
|
||||
" unloading = true;\n"
|
||||
" ++connectionGeneration;\n"
|
||||
" clearReconnectTimer();\n"
|
||||
" if (statusTimer !== null) window.clearInterval(statusTimer);\n"
|
||||
" if (ticketAbort !== null) ticketAbort.abort();\n"
|
||||
" if (socket !== null) socket.close();\n"
|
||||
" socket = null;\n"
|
||||
"};\n"
|
||||
"window.addEventListener('pagehide', shutdown, {once: true});\n"
|
||||
"updateControls();\n"
|
||||
"scheduleFit();\n"
|
||||
"pollStatus();\n"
|
||||
"statusTimer = window.setInterval(pollStatus, 5000);\n"
|
||||
"connect();\n"
|
||||
"})();\n";
|
||||
|
||||
typedef struct {
|
||||
const char *content_type;
|
||||
const char *cache_control;
|
||||
const char *content_encoding;
|
||||
const uint8_t *data;
|
||||
size_t length;
|
||||
bool content_security_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->content_security_policy = false;
|
||||
|
||||
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->content_security_policy = true;
|
||||
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_DOCUMENT_CACHE_CONTROL;
|
||||
response->data = (const uint8_t *)s_app_js;
|
||||
response->length = sizeof(s_app_js) - 1U;
|
||||
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->content_security_policy) {
|
||||
result = httpd_resp_set_hdr(
|
||||
request, "Content-Security-Policy",
|
||||
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
|
||||
"connect-src 'self'; base-uri 'none'; form-action 'none'; "
|
||||
"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;
|
||||
}
|
||||
Reference in New Issue
Block a user