Implements admin-only Display settings with generation-checked Apply, Save, Load, Defaults, and Reset operations across the web UI, CLI, SSH dispatcher, and local UI owner. Adds bounded HTTP handling, session-isolated operation results, browser lifecycle support, and comprehensive host tests and documentation.
237 lines
10 KiB
C
237 lines
10 KiB
C
/* SPDX-License-Identifier: GPL-3.0-only */
|
|
#include "web_display_settings.h"
|
|
|
|
#include <inttypes.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
#include "admin_ssh_console.h"
|
|
#include "esp_timer.h"
|
|
#include "freertos/FreeRTOS.h"
|
|
#include "secure_random.h"
|
|
#include "local_status_ui.h"
|
|
#include "web_cookie_auth.h"
|
|
#include "web_httpd_adapter.h"
|
|
|
|
enum { APPLY, SAVE, LOAD, DEFAULTS, RESET, ACTION_COUNT };
|
|
static const char *const s_actions[] = {"apply", "save", "load", "defaults", "reset"};
|
|
enum { IDLE, PENDING, OK, FAILED, CANCELLED, LOADED_DEFAULTS, CONFLICT };
|
|
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "loaded_defaults", "conflict"};
|
|
typedef struct {
|
|
uint32_t id;
|
|
web_session_id_t session;
|
|
user_principal_t principal;
|
|
int64_t deadline;
|
|
local_ui_config_t config;
|
|
uint32_t generation;
|
|
unsigned action, state;
|
|
} display_operation_t;
|
|
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
|
static display_operation_t s_operation;
|
|
static uint32_t s_next_id;
|
|
|
|
/* Deliberately narrow flat JSON: ASCII names/enums, unsigned decimal integers,
|
|
* no escapes, nesting, duplicate/unknown fields, exponent or fractional values. */
|
|
static bool parse(const char *body, size_t length, display_operation_t *operation)
|
|
{
|
|
const char *keys[] = {"action", "generation", "dim_seconds", "off_seconds"};
|
|
unsigned seen = 0;
|
|
size_t pos = 0;
|
|
local_ui_config_defaults(&operation->config);
|
|
operation->action = ACTION_COUNT;
|
|
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
|
|
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
|
|
TAKE('{');
|
|
for (unsigned field = 0; field < 4; ++field) {
|
|
if (field) { TAKE(','); }
|
|
TAKE('"');
|
|
size_t start = pos;
|
|
while (pos < length && body[pos] != '"') ++pos;
|
|
if (pos == length) return false;
|
|
unsigned key = 0;
|
|
for (; key < 4; ++key)
|
|
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
|
|
if (key == 4 || (seen & (1U << key))) return false;
|
|
++pos; TAKE(':'); SPACE();
|
|
if (key == 0) {
|
|
TAKE('"'); start = pos;
|
|
while (pos < length && body[pos] != '"') ++pos;
|
|
if (pos == length) return false;
|
|
for (unsigned i = 0; i < ACTION_COUNT; ++i)
|
|
if (strlen(s_actions[i]) == pos - start && !memcmp(body + start, s_actions[i], pos - start)) operation->action = i;
|
|
if (operation->action == ACTION_COUNT) return false;
|
|
++pos;
|
|
} else {
|
|
uint32_t number = 0;
|
|
start = pos;
|
|
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
|
|
unsigned digit = (unsigned)(body[pos++] - '0');
|
|
if (number > (UINT32_MAX - digit) / 10U) return false;
|
|
number = number * 10U + digit;
|
|
}
|
|
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
|
|
if (key == 1) operation->generation = number;
|
|
if (key == 2) operation->config.dim_timeout_seconds = number;
|
|
if (key == 3) operation->config.off_timeout_seconds = number;
|
|
}
|
|
seen |= 1U << key;
|
|
SPACE();
|
|
if (pos < length && body[pos] == '}') break;
|
|
}
|
|
TAKE('}'); SPACE();
|
|
#undef TAKE
|
|
#undef SPACE
|
|
return pos == length && seen == (operation->action == APPLY ? 15U : 3U) &&
|
|
operation->generation != 0 && local_ui_config_validate(&operation->config) == ESP_OK;
|
|
}
|
|
|
|
void web_display_settings_execute(uint32_t id)
|
|
{
|
|
display_operation_t operation;
|
|
taskENTER_CRITICAL(&s_lock);
|
|
operation = s_operation;
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
if (!id || operation.id != id || operation.state != PENDING) {
|
|
secure_wipe(&operation, sizeof(operation));
|
|
return;
|
|
}
|
|
bool current = false;
|
|
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, ¤t);
|
|
unsigned state = CANCELLED;
|
|
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
|
|
esp_timer_get_time() < operation.deadline) {
|
|
/* The owner checks the selected generation and reserves all config
|
|
* mutations, including CLI callers, across storage IO. Buttons only
|
|
* signal activity: they never replace configuration or own this gate. */
|
|
bool defaults = false;
|
|
static const local_ui_settings_action_t actions[] = {
|
|
LOCAL_UI_SETTINGS_APPLY, LOCAL_UI_SETTINGS_SAVE, LOCAL_UI_SETTINGS_LOAD,
|
|
LOCAL_UI_SETTINGS_DEFAULTS, LOCAL_UI_SETTINGS_RESET
|
|
};
|
|
error = local_status_ui_update_settings(actions[operation.action], operation.generation,
|
|
&operation.config, &defaults);
|
|
state = error == ESP_OK ? (defaults ? LOADED_DEFAULTS : OK) :
|
|
error == ESP_ERR_INVALID_STATE ? CONFLICT : FAILED;
|
|
}
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (s_operation.id == id && s_operation.state == PENDING) {
|
|
s_operation.state = state;
|
|
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
|
|
secure_wipe(&s_operation.config, sizeof(s_operation.config));
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
secure_wipe(&operation, sizeof(operation));
|
|
}
|
|
|
|
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
|
|
{
|
|
esp_err_t error = httpd_resp_set_status(request, status);
|
|
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
|
|
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
|
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
|
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
|
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
|
|
return web_httpd_unread_body(request) ? ESP_FAIL : error;
|
|
}
|
|
|
|
esp_err_t web_display_operation_handler(httpd_req_t *request)
|
|
{
|
|
web_session_view_t view = {0};
|
|
bool allowed = false;
|
|
bool mutation = request->method == HTTP_POST;
|
|
esp_err_t error = mutation
|
|
? web_cookie_auth_require_json(request, 256, &view, &allowed)
|
|
: web_cookie_auth_require(request, false, false, &view, &allowed);
|
|
if (error != ESP_OK || !allowed) goto done;
|
|
if (view.principal.role != USER_ROLE_ADMIN) {
|
|
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
|
goto done;
|
|
}
|
|
display_operation_t operation = {0};
|
|
if (mutation) {
|
|
char type[40] = {0}, body[256];
|
|
size_t received = 0;
|
|
bool valid = request->content_len &&
|
|
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
|
|
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
|
|
/* Finite bytes and receive calls; timeout/error closes, never retry/drain. */
|
|
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
|
|
int count = httpd_req_recv(request, body + received, request->content_len - received);
|
|
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
|
|
else received += (size_t)count;
|
|
}
|
|
valid = valid && received == request->content_len && parse(body, received, &operation);
|
|
secure_wipe(body, sizeof(body));
|
|
if (!valid) {
|
|
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_display_request\"}");
|
|
goto done;
|
|
}
|
|
operation.session = view.id;
|
|
operation.principal = view.principal;
|
|
operation.deadline = esp_timer_get_time() + 30000000LL;
|
|
operation.state = PENDING;
|
|
taskENTER_CRITICAL(&s_lock);
|
|
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
|
|
if (!busy) {
|
|
operation.id = ++s_next_id;
|
|
s_operation = operation;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
if (busy || admin_ssh_console_submit_display_settings(operation.id) != ESP_OK) {
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
error = httpd_resp_set_hdr(request, "Retry-After", "1");
|
|
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
|
|
secure_wipe(&operation, sizeof(operation));
|
|
goto done;
|
|
}
|
|
} else {
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (s_operation.session == view.id) {
|
|
operation.id = s_operation.id;
|
|
operation.action = s_operation.action;
|
|
operation.state = s_operation.state;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
}
|
|
char response[96];
|
|
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
|
|
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]);
|
|
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
|
|
respond(request, mutation ? "202 Accepted" : "200 OK", response);
|
|
secure_wipe(&operation, sizeof(operation));
|
|
done:
|
|
secure_wipe(&view, sizeof(view));
|
|
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
|
return error;
|
|
}
|
|
|
|
esp_err_t web_display_settings_handler(httpd_req_t *request)
|
|
{
|
|
web_session_view_t view = {0};
|
|
bool allowed = false;
|
|
esp_err_t error = web_cookie_auth_require(request, false, false, &view, &allowed);
|
|
if (error != ESP_OK || !allowed) goto done;
|
|
if (view.principal.role != USER_ROLE_ADMIN) {
|
|
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
|
goto done;
|
|
}
|
|
local_ui_config_t config;
|
|
uint32_t generation;
|
|
error = local_status_ui_get_settings(&config, &generation);
|
|
if (error != ESP_OK) {
|
|
error = respond(request, "503 Service Unavailable", "{\"error\":\"display_unavailable\"}");
|
|
goto done;
|
|
}
|
|
char response[128];
|
|
int written = snprintf(response, sizeof(response),
|
|
"{\"generation\":%" PRIu32 ",\"dim_seconds\":%" PRIu32 ",\"off_seconds\":%" PRIu32 "}",
|
|
generation, config.dim_timeout_seconds, config.off_timeout_seconds);
|
|
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
|
|
respond(request, "200 OK", response);
|
|
done:
|
|
secure_wipe(&view, sizeof(view));
|
|
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
|
return error;
|
|
}
|