Files
ESP32_Serial_Swiss_Army_Knife/src/web_ui.c
T
Commander1024 c018bfe361 Constrain terminal layout and externalize TLS memory
Improve CSP and favicon handling while preventing terminal resize loops.
Configure mbedTLS allocations in external PSRAM and document its
security implications.
2026-08-24 20:52:10 +02:00

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;
}