Improve CSP and favicon handling while preventing terminal resize loops. Configure mbedTLS allocations in external PSRAM and document its security implications.
534 lines
25 KiB
C
534 lines
25 KiB
C
/* 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;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"
|
|
".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"
|
|
".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-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-title{color:var(--text);font-weight:750;letter-spacing:.02em}\n"
|
|
"#terminal{flex:1;min-width:0;min-height:0;border-radius:9px;overflow:hidden;"
|
|
"background:#080c12;padding:8px}\n"
|
|
"#terminal .xterm{width:100%;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(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\"><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 lastFitWidth = 0;\n"
|
|
"let lastFitHeight = 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 fitTerminal = () => {\n"
|
|
" fitFrame = 0;\n"
|
|
" const bounds = terminalHost.getBoundingClientRect();\n"
|
|
" const width = Math.floor(bounds.width);\n"
|
|
" const height = Math.floor(bounds.height);\n"
|
|
" if (width < 1 || height < 1 || (width === lastFitWidth && height === lastFitHeight)) return;\n"
|
|
" lastFitWidth = width;\n"
|
|
" lastFitHeight = height;\n"
|
|
" try {\n"
|
|
" const dimensions = fitAddon.proposeDimensions();\n"
|
|
" if (dimensions && dimensions.cols > 0 && dimensions.rows > 0 &&\n"
|
|
" (dimensions.cols !== terminal.cols || dimensions.rows !== terminal.rows)) {\n"
|
|
" terminal.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(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 (fitFrame !== 0) window.cancelAnimationFrame(fitFrame);\n"
|
|
" if (resizeObserver !== null) resizeObserver.disconnect();\n"
|
|
" window.removeEventListener('resize', scheduleFit);\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'; script-src-elem 'self'; "
|
|
"style-src 'self' 'unsafe-inline'; img-src data:; 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;
|
|
}
|