Harden web UI auth, input handling, and SD path validation
- Add optional Basic Auth with NVS-backed credentials and STA/AP flags; protect status, wifi, history, and download routes - Stop pre-filling WiFi/MQTT/Web UI password fields; keep stored secrets on blank and add clear-password checkboxes - Add HTML escaping + URL encoding helpers and apply to user-controlled strings; add unit test - Harden /sd/download path validation (prefix, length, dotdot, slashes) and log rejections - Enforce protocol version in LoRa receive and release GPIO14 before SD init - Update README security, SD, and GPIO sharing notes
This commit is contained in:
23
README.md
23
README.md
@@ -31,10 +31,10 @@ Variants:
|
|||||||
- SCL: GPIO22
|
- SCL: GPIO22
|
||||||
- I2C address: 0x68
|
- I2C address: 0x68
|
||||||
- Battery ADC: GPIO35 (via on-board divider)
|
- Battery ADC: GPIO35 (via on-board divider)
|
||||||
- **Role select**: GPIO14 (INPUT_PULLDOWN, sampled at boot)
|
- **Role select**: GPIO14 (INPUT_PULLDOWN, sampled at boot, **shared with SD SCK**)
|
||||||
- HIGH = Sender
|
- HIGH = Sender
|
||||||
- LOW/floating = Receiver
|
- LOW/floating = Receiver
|
||||||
- **OLED control**: GPIO13 (INPUT_PULLDOWN, sender only)
|
- **OLED control**: GPIO13 (INPUT_PULLDOWN, sender only, **shared with SD CS**)
|
||||||
- HIGH = force OLED on
|
- HIGH = force OLED on
|
||||||
- LOW = allow auto-off after timeout
|
- LOW = allow auto-off after timeout
|
||||||
- Not used on receiver (OLED always on)
|
- Not used on receiver (OLED always on)
|
||||||
@@ -43,6 +43,9 @@ Variants:
|
|||||||
### Notes on GPIOs
|
### Notes on GPIOs
|
||||||
- GPIO34/35/36/39 are input-only and have **no internal pullups/pulldowns**.
|
- GPIO34/35/36/39 are input-only and have **no internal pullups/pulldowns**.
|
||||||
- Strap pins (GPIO0/2/4/5/12/15) can affect boot; avoid for role or control jumpers.
|
- Strap pins (GPIO0/2/4/5/12/15) can affect boot; avoid for role or control jumpers.
|
||||||
|
- GPIO14 is shared between role select and SD SCK. **Do not attach the role jumper in Receiver mode if the SD card is connected/used**, and never force GPIO14 high when using SD.
|
||||||
|
- GPIO13 is shared between OLED control and SD CS. Avoid driving OLED control when SD is active.
|
||||||
|
- Receiver firmware releases GPIO14 to `INPUT` (no pulldown) after boot before SD SPI init.
|
||||||
|
|
||||||
## Firmware Roles
|
## Firmware Roles
|
||||||
### Sender (battery-powered)
|
### Sender (battery-powered)
|
||||||
@@ -262,10 +265,20 @@ inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { 0xF19C };
|
|||||||
- `/wifi`: WiFi/MQTT/NTP config (AP and STA)
|
- `/wifi`: WiFi/MQTT/NTP config (AP and STA)
|
||||||
- `/sender/<device_id>`: per-sender details
|
- `/sender/<device_id>`: per-sender details
|
||||||
- Sender IDs on `/` are clickable (open sender page in a new tab).
|
- Sender IDs on `/` are clickable (open sender page in a new tab).
|
||||||
- In STA mode, the UI is also available via the board’s IP/hostname on your WiFi network.
|
- In STA mode, the UI is also available via the board's IP/hostname on your WiFi network.
|
||||||
- Main page shows SD card file listing (downloadable).
|
- Main page shows SD card file listing (downloadable).
|
||||||
- Sender page includes a history chart (power) with configurable range/resolution/mode.
|
- Sender page includes a history chart (power) with configurable range/resolution/mode.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
- Basic Auth is supported for the web UI. In STA mode it is enabled by default; AP mode is optional.
|
||||||
|
- Config flags in `include/config.h`:
|
||||||
|
- `WEB_AUTH_REQUIRE_STA` (default `true`)
|
||||||
|
- `WEB_AUTH_REQUIRE_AP` (default `false`)
|
||||||
|
- `WEB_AUTH_DEFAULT_USER` / `WEB_AUTH_DEFAULT_PASS`
|
||||||
|
- Web credentials are stored in NVS. `/wifi`, `/sd/download`, `/history/*`, `/`, `/sender/*`, and `/manual` require auth when enabled.
|
||||||
|
- Password inputs are not prefilled. Leaving a password blank keeps the stored value; use the "clear password" checkbox to erase it.
|
||||||
|
- User-controlled strings are HTML-escaped before embedding in pages.
|
||||||
|
|
||||||
## MQTT
|
## MQTT
|
||||||
- Topic: `smartmeter/<deviceId>/state`
|
- Topic: `smartmeter/<deviceId>/state`
|
||||||
- QoS 0
|
- QoS 0
|
||||||
@@ -303,6 +316,7 @@ Key timing settings in `include/config.h`:
|
|||||||
- `ENABLE_SD_LOGGING` / `PIN_SD_CS`
|
- `ENABLE_SD_LOGGING` / `PIN_SD_CS`
|
||||||
- `SD_HISTORY_MAX_DAYS` / `SD_HISTORY_MIN_RES_MIN`
|
- `SD_HISTORY_MAX_DAYS` / `SD_HISTORY_MIN_RES_MIN`
|
||||||
- `SD_HISTORY_MAX_BINS` / `SD_HISTORY_TIME_BUDGET_MS`
|
- `SD_HISTORY_MAX_BINS` / `SD_HISTORY_TIME_BUDGET_MS`
|
||||||
|
- `WEB_AUTH_REQUIRE_STA` / `WEB_AUTH_REQUIRE_AP` / `WEB_AUTH_DEFAULT_USER` / `WEB_AUTH_DEFAULT_PASS`
|
||||||
|
|
||||||
## Limits & Known Constraints
|
## Limits & Known Constraints
|
||||||
- **Compression**: MeterData uses lightweight RLE (good for JSON but not optimal).
|
- **Compression**: MeterData uses lightweight RLE (good for JSON but not optimal).
|
||||||
@@ -320,6 +334,7 @@ Optional CSV logging to microSD (FAT32) when `ENABLE_SD_LOGGING = true`.
|
|||||||
`ts_utc,p_w,p1_w,p2_w,p3_w,e_kwh,bat_v,bat_pct,rssi,snr,err_m,err_d,err_tx,err_last`
|
`ts_utc,p_w,p1_w,p2_w,p3_w,e_kwh,bat_v,bat_pct,rssi,snr,err_m,err_d,err_tx,err_last`
|
||||||
- `err_last` is written as text (`meter`, `decode`, `loratx`) only on the last sample of a batch that reports an error.
|
- `err_last` is written as text (`meter`, `decode`, `loratx`) only on the last sample of a batch that reports an error.
|
||||||
- Files are downloadable from the main UI page.
|
- Files are downloadable from the main UI page.
|
||||||
|
- Downloads only allow absolute paths under `/dd3/`, reject `..`, backslashes, and repeated slashes, and enforce a max path length.
|
||||||
- History chart on sender page stream-parses CSVs and bins data in the background.
|
- History chart on sender page stream-parses CSVs and bins data in the background.
|
||||||
- SD uses the on-board microSD SPI pins (CS=13, MOSI=15, SCK=14, MISO=2).
|
- SD uses the on-board microSD SPI pins (CS=13, MOSI=15, SCK=14, MISO=2).
|
||||||
|
|
||||||
@@ -341,7 +356,7 @@ Optional CSV logging to microSD (FAT32) when `ENABLE_SD_LOGGING = true`.
|
|||||||
- `src/main.cpp`: role detection and main loop
|
- `src/main.cpp`: role detection and main loop
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
1. Set role jumper on GPIO13:
|
1. Set role jumper on GPIO14:
|
||||||
- LOW: sender
|
- LOW: sender
|
||||||
- HIGH: receiver
|
- HIGH: receiver
|
||||||
2. OLED control on GPIO13:
|
2. OLED control on GPIO13:
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ constexpr uint16_t SD_HISTORY_MAX_BINS = 4000;
|
|||||||
constexpr uint16_t SD_HISTORY_TIME_BUDGET_MS = 10;
|
constexpr uint16_t SD_HISTORY_TIME_BUDGET_MS = 10;
|
||||||
constexpr const char *AP_SSID_PREFIX = "DD3-Bridge-";
|
constexpr const char *AP_SSID_PREFIX = "DD3-Bridge-";
|
||||||
constexpr const char *AP_PASSWORD = "changeme123";
|
constexpr const char *AP_PASSWORD = "changeme123";
|
||||||
|
constexpr bool WEB_AUTH_REQUIRE_STA = true;
|
||||||
|
constexpr bool WEB_AUTH_REQUIRE_AP = false;
|
||||||
|
constexpr const char *WEB_AUTH_DEFAULT_USER = "admin";
|
||||||
|
constexpr const char *WEB_AUTH_DEFAULT_PASS = "admin";
|
||||||
|
|
||||||
constexpr uint8_t NUM_SENDERS = 1;
|
constexpr uint8_t NUM_SENDERS = 1;
|
||||||
inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = {
|
inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = {
|
||||||
|
|||||||
6
include/html_util.h
Normal file
6
include/html_util.h
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
String html_escape(const String &input);
|
||||||
|
String url_encode_component(const String &input);
|
||||||
@@ -12,6 +12,8 @@ struct WifiMqttConfig {
|
|||||||
String mqtt_pass;
|
String mqtt_pass;
|
||||||
String ntp_server_1;
|
String ntp_server_1;
|
||||||
String ntp_server_2;
|
String ntp_server_2;
|
||||||
|
String web_user;
|
||||||
|
String web_pass;
|
||||||
bool valid;
|
bool valid;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
49
src/html_util.cpp
Normal file
49
src/html_util.cpp
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#include "html_util.h"
|
||||||
|
|
||||||
|
String html_escape(const String &input) {
|
||||||
|
String out;
|
||||||
|
out.reserve(input.length() + 8);
|
||||||
|
for (size_t i = 0; i < input.length(); ++i) {
|
||||||
|
char c = input[i];
|
||||||
|
switch (c) {
|
||||||
|
case '&':
|
||||||
|
out += "&";
|
||||||
|
break;
|
||||||
|
case '<':
|
||||||
|
out += "<";
|
||||||
|
break;
|
||||||
|
case '>':
|
||||||
|
out += ">";
|
||||||
|
break;
|
||||||
|
case '"':
|
||||||
|
out += """;
|
||||||
|
break;
|
||||||
|
case '\'':
|
||||||
|
out += "'";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
out += c;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
String url_encode_component(const String &input) {
|
||||||
|
String out;
|
||||||
|
out.reserve(input.length() * 3);
|
||||||
|
const char *hex = "0123456789ABCDEF";
|
||||||
|
for (size_t i = 0; i < input.length(); ++i) {
|
||||||
|
unsigned char c = static_cast<unsigned char>(input[i]);
|
||||||
|
bool safe = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||||
|
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~';
|
||||||
|
if (safe) {
|
||||||
|
out += static_cast<char>(c);
|
||||||
|
} else {
|
||||||
|
out += '%';
|
||||||
|
out += hex[(c >> 4) & 0x0F];
|
||||||
|
out += hex[c & 0x0F];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -85,6 +85,9 @@ bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) {
|
|||||||
if (crc_calc != crc_rx) {
|
if (crc_calc != crc_rx) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (buffer[0] != PROTOCOL_VERSION) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
pkt.protocol_version = buffer[0];
|
pkt.protocol_version = buffer[0];
|
||||||
pkt.role = static_cast<DeviceRole>(buffer[1]);
|
pkt.role = static_cast<DeviceRole>(buffer[1]);
|
||||||
|
|||||||
@@ -626,6 +626,7 @@ void setup() {
|
|||||||
update_battery_cache();
|
update_battery_cache();
|
||||||
} else {
|
} else {
|
||||||
power_receiver_init();
|
power_receiver_init();
|
||||||
|
pinMode(PIN_ROLE, INPUT); // release pulldown before SD uses GPIO14 as SCK
|
||||||
sd_logger_init();
|
sd_logger_init();
|
||||||
wifi_manager_init();
|
wifi_manager_init();
|
||||||
init_sender_statuses();
|
init_sender_statuses();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "sd_logger.h"
|
#include "sd_logger.h"
|
||||||
#include "time_manager.h"
|
#include "time_manager.h"
|
||||||
|
#include "html_util.h"
|
||||||
#include <SD.h>
|
#include <SD.h>
|
||||||
#include <WiFi.h>
|
#include <WiFi.h>
|
||||||
#include <time.h>
|
#include <time.h>
|
||||||
@@ -15,6 +16,8 @@ static const SenderStatus *g_statuses = nullptr;
|
|||||||
static uint8_t g_status_count = 0;
|
static uint8_t g_status_count = 0;
|
||||||
static WifiMqttConfig g_config;
|
static WifiMqttConfig g_config;
|
||||||
static bool g_is_ap = false;
|
static bool g_is_ap = false;
|
||||||
|
static String g_web_user;
|
||||||
|
static String g_web_pass;
|
||||||
static const FaultCounters *g_sender_faults = nullptr;
|
static const FaultCounters *g_sender_faults = nullptr;
|
||||||
static const FaultType *g_sender_last_errors = nullptr;
|
static const FaultType *g_sender_last_errors = nullptr;
|
||||||
static MeterData g_last_batch[NUM_SENDERS][METER_BATCH_MAX_SAMPLES];
|
static MeterData g_last_batch[NUM_SENDERS][METER_BATCH_MAX_SAMPLES];
|
||||||
@@ -50,11 +53,30 @@ struct HistoryJob {
|
|||||||
|
|
||||||
static HistoryJob g_history = {};
|
static HistoryJob g_history = {};
|
||||||
static constexpr size_t SD_LIST_MAX_FILES = 200;
|
static constexpr size_t SD_LIST_MAX_FILES = 200;
|
||||||
|
static constexpr size_t SD_DOWNLOAD_MAX_PATH = 160;
|
||||||
|
|
||||||
|
static bool auth_required() {
|
||||||
|
return g_is_ap ? WEB_AUTH_REQUIRE_AP : WEB_AUTH_REQUIRE_STA;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool ensure_auth() {
|
||||||
|
if (!auth_required()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const char *user = g_web_user.c_str();
|
||||||
|
const char *pass = g_web_pass.c_str();
|
||||||
|
if (server.authenticate(user, pass)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
server.requestAuthentication(BASIC_AUTH, "DD3", "Authentication required");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
static String html_header(const String &title) {
|
static String html_header(const String &title) {
|
||||||
|
String safe_title = html_escape(title);
|
||||||
String h = "<!DOCTYPE html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>";
|
String h = "<!DOCTYPE html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>";
|
||||||
h += "<title>" + title + "</title></head><body>";
|
h += "<title>" + safe_title + "</title></head><body>";
|
||||||
h += "<h2>" + title + "</h2>";
|
h += "<h2>" + safe_title + "</h2>";
|
||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +100,46 @@ static String format_faults(uint8_t idx) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool sanitize_sd_download_path(String &path, String &error) {
|
||||||
|
path.trim();
|
||||||
|
if (path.length() == 0) {
|
||||||
|
error = "empty";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (path.startsWith("dd3/")) {
|
||||||
|
path = "/" + path;
|
||||||
|
}
|
||||||
|
if (path.length() > SD_DOWNLOAD_MAX_PATH) {
|
||||||
|
error = "too_long";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!path.startsWith("/dd3/")) {
|
||||||
|
error = "prefix";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (path.indexOf("..") >= 0) {
|
||||||
|
error = "dotdot";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (path.indexOf('\\') >= 0) {
|
||||||
|
error = "backslash";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (path.indexOf("//") >= 0) {
|
||||||
|
error = "repeated_slash";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool checkbox_checked(const char *name) {
|
||||||
|
if (!server.hasArg(name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String val = server.arg(name);
|
||||||
|
return val == "on" || val == "true" || val == "1";
|
||||||
|
}
|
||||||
|
|
||||||
static void history_reset() {
|
static void history_reset() {
|
||||||
if (g_history.file) {
|
if (g_history.file) {
|
||||||
g_history.file.close();
|
g_history.file.close();
|
||||||
@@ -228,7 +290,9 @@ static String render_sender_block(const SenderStatus &status) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
String device_id = status.last_data.device_id;
|
String device_id = status.last_data.device_id;
|
||||||
s += "<strong><a href='/sender/" + device_id + "' target='_blank' rel='noopener noreferrer'>" + device_id + "</a></strong>";
|
String device_id_safe = html_escape(device_id);
|
||||||
|
String device_id_url = url_encode_component(device_id);
|
||||||
|
s += "<strong><a href='/sender/" + device_id_url + "' target='_blank' rel='noopener noreferrer'>" + device_id_safe + "</a></strong>";
|
||||||
if (status.has_data && status.last_data.link_valid) {
|
if (status.has_data && status.last_data.link_valid) {
|
||||||
s += " RSSI:" + String(status.last_data.link_rssi_dbm) + " SNR:" + String(status.last_data.link_snr_db, 1);
|
s += " RSSI:" + String(status.last_data.link_rssi_dbm) + " SNR:" + String(status.last_data.link_snr_db, 1);
|
||||||
}
|
}
|
||||||
@@ -271,14 +335,15 @@ static void append_sd_listing(String &html, const String &dir_path, uint8_t dept
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
html += "<li><strong>" + full_path + "/</strong></li>";
|
html += "<li><strong>" + html_escape(full_path) + "/</strong></li>";
|
||||||
append_sd_listing(html, full_path, depth + 1, count);
|
append_sd_listing(html, full_path, depth + 1, count);
|
||||||
} else {
|
} else {
|
||||||
String href = full_path;
|
String href = full_path;
|
||||||
if (!href.startsWith("/")) {
|
if (!href.startsWith("/")) {
|
||||||
href = "/" + href;
|
href = "/" + href;
|
||||||
}
|
}
|
||||||
html += "<li><a href='/sd/download?path=" + href + "' target='_blank' rel='noopener noreferrer'>" + full_path + "</a>";
|
String href_enc = url_encode_component(href);
|
||||||
|
html += "<li><a href='/sd/download?path=" + href_enc + "' target='_blank' rel='noopener noreferrer'>" + html_escape(full_path) + "</a>";
|
||||||
html += " (" + String(entry.size()) + " bytes)</li>";
|
html += " (" + String(entry.size()) + " bytes)</li>";
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
@@ -288,6 +353,9 @@ static void append_sd_listing(String &html, const String &dir_path, uint8_t dept
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void handle_root() {
|
static void handle_root() {
|
||||||
|
if (!ensure_auth()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
String html = html_header("DD3 Bridge Status");
|
String html = html_header("DD3 Bridge Status");
|
||||||
html += g_is_ap ? "<p>Mode: AP</p>" : "<p>Mode: STA</p>";
|
html += g_is_ap ? "<p>Mode: AP</p>" : "<p>Mode: STA</p>";
|
||||||
|
|
||||||
@@ -316,16 +384,26 @@ static void handle_root() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void handle_wifi_get() {
|
static void handle_wifi_get() {
|
||||||
|
if (!ensure_auth()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
String html = html_header("WiFi/MQTT Config");
|
String html = html_header("WiFi/MQTT Config");
|
||||||
html += "<form method='POST' action='/wifi'>";
|
html += "<form method='POST' action='/wifi'>";
|
||||||
html += "SSID: <input name='ssid' value='" + g_config.ssid + "'><br>";
|
html += "SSID: <input name='ssid' value='" + html_escape(g_config.ssid) + "'><br>";
|
||||||
html += "Password: <input name='pass' type='password' value='" + g_config.password + "'><br>";
|
html += "Password: <input name='pass' type='password'> ";
|
||||||
html += "MQTT Host: <input name='mqhost' value='" + g_config.mqtt_host + "'><br>";
|
html += "<label><input type='checkbox' name='clear_wifi_pass'> Clear password</label><br>";
|
||||||
|
html += "MQTT Host: <input name='mqhost' value='" + html_escape(g_config.mqtt_host) + "'><br>";
|
||||||
html += "MQTT Port: <input name='mqport' value='" + String(g_config.mqtt_port) + "'><br>";
|
html += "MQTT Port: <input name='mqport' value='" + String(g_config.mqtt_port) + "'><br>";
|
||||||
html += "MQTT User: <input name='mquser' value='" + g_config.mqtt_user + "'><br>";
|
html += "MQTT User: <input name='mquser' value='" + html_escape(g_config.mqtt_user) + "'><br>";
|
||||||
html += "MQTT Pass: <input name='mqpass' type='password' value='" + g_config.mqtt_pass + "'><br>";
|
html += "MQTT Pass: <input name='mqpass' type='password'> ";
|
||||||
html += "NTP Server 1: <input name='ntp1' value='" + g_config.ntp_server_1 + "'><br>";
|
html += "<label><input type='checkbox' name='clear_mqtt_pass'> Clear password</label><br>";
|
||||||
html += "NTP Server 2: <input name='ntp2' value='" + g_config.ntp_server_2 + "'><br>";
|
html += "NTP Server 1: <input name='ntp1' value='" + html_escape(g_config.ntp_server_1) + "'><br>";
|
||||||
|
html += "NTP Server 2: <input name='ntp2' value='" + html_escape(g_config.ntp_server_2) + "'><br>";
|
||||||
|
html += "<hr>";
|
||||||
|
html += "Web UI User: <input name='webuser' value='" + html_escape(g_config.web_user) + "'><br>";
|
||||||
|
html += "Web UI Pass: <input name='webpass' type='password'> ";
|
||||||
|
html += "<label><input type='checkbox' name='clear_web_pass'> Clear password</label><br>";
|
||||||
|
html += "<div style='font-size:12px;color:#666;'>Leaving password blank keeps the existing one.</div>";
|
||||||
html += "<button type='submit'>Save</button>";
|
html += "<button type='submit'>Save</button>";
|
||||||
html += "</form>";
|
html += "</form>";
|
||||||
html += html_footer();
|
html += html_footer();
|
||||||
@@ -333,15 +411,38 @@ static void handle_wifi_get() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void handle_wifi_post() {
|
static void handle_wifi_post() {
|
||||||
WifiMqttConfig cfg;
|
if (!ensure_auth()) {
|
||||||
cfg.ntp_server_1 = "pool.ntp.org";
|
return;
|
||||||
cfg.ntp_server_2 = "time.nist.gov";
|
}
|
||||||
|
WifiMqttConfig cfg = g_config;
|
||||||
|
cfg.ntp_server_1 = g_config.ntp_server_1.length() > 0 ? g_config.ntp_server_1 : "pool.ntp.org";
|
||||||
|
cfg.ntp_server_2 = g_config.ntp_server_2.length() > 0 ? g_config.ntp_server_2 : "time.nist.gov";
|
||||||
cfg.ssid = server.arg("ssid");
|
cfg.ssid = server.arg("ssid");
|
||||||
cfg.password = server.arg("pass");
|
String wifi_pass = server.arg("pass");
|
||||||
|
if (checkbox_checked("clear_wifi_pass")) {
|
||||||
|
cfg.password = "";
|
||||||
|
} else if (wifi_pass.length() > 0) {
|
||||||
|
cfg.password = wifi_pass;
|
||||||
|
}
|
||||||
cfg.mqtt_host = server.arg("mqhost");
|
cfg.mqtt_host = server.arg("mqhost");
|
||||||
cfg.mqtt_port = static_cast<uint16_t>(server.arg("mqport").toInt());
|
cfg.mqtt_port = static_cast<uint16_t>(server.arg("mqport").toInt());
|
||||||
cfg.mqtt_user = server.arg("mquser");
|
cfg.mqtt_user = server.arg("mquser");
|
||||||
cfg.mqtt_pass = server.arg("mqpass");
|
String mqtt_pass = server.arg("mqpass");
|
||||||
|
if (checkbox_checked("clear_mqtt_pass")) {
|
||||||
|
cfg.mqtt_pass = "";
|
||||||
|
} else if (mqtt_pass.length() > 0) {
|
||||||
|
cfg.mqtt_pass = mqtt_pass;
|
||||||
|
}
|
||||||
|
String web_user = server.arg("webuser");
|
||||||
|
if (web_user.length() > 0) {
|
||||||
|
cfg.web_user = web_user;
|
||||||
|
}
|
||||||
|
String web_pass = server.arg("webpass");
|
||||||
|
if (checkbox_checked("clear_web_pass")) {
|
||||||
|
cfg.web_pass = "";
|
||||||
|
} else if (web_pass.length() > 0) {
|
||||||
|
cfg.web_pass = web_pass;
|
||||||
|
}
|
||||||
if (server.arg("ntp1").length() > 0) {
|
if (server.arg("ntp1").length() > 0) {
|
||||||
cfg.ntp_server_1 = server.arg("ntp1");
|
cfg.ntp_server_1 = server.arg("ntp1");
|
||||||
}
|
}
|
||||||
@@ -349,6 +450,9 @@ static void handle_wifi_post() {
|
|||||||
cfg.ntp_server_2 = server.arg("ntp2");
|
cfg.ntp_server_2 = server.arg("ntp2");
|
||||||
}
|
}
|
||||||
cfg.valid = true;
|
cfg.valid = true;
|
||||||
|
g_config = cfg;
|
||||||
|
g_web_user = cfg.web_user;
|
||||||
|
g_web_pass = cfg.web_pass;
|
||||||
wifi_save_config(cfg);
|
wifi_save_config(cfg);
|
||||||
server.send(200, "text/html", "<html><body>Saved. Rebooting...</body></html>");
|
server.send(200, "text/html", "<html><body>Saved. Rebooting...</body></html>");
|
||||||
delay(1000);
|
delay(1000);
|
||||||
@@ -356,12 +460,16 @@ static void handle_wifi_post() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void handle_sender() {
|
static void handle_sender() {
|
||||||
|
if (!ensure_auth()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!g_statuses) {
|
if (!g_statuses) {
|
||||||
server.send(404, "text/plain", "No senders");
|
server.send(404, "text/plain", "No senders");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String uri = server.uri();
|
String uri = server.uri();
|
||||||
String device_id = uri.substring(String("/sender/").length());
|
String device_id = uri.substring(String("/sender/").length());
|
||||||
|
String device_id_url = url_encode_component(device_id);
|
||||||
for (uint8_t i = 0; i < g_status_count; ++i) {
|
for (uint8_t i = 0; i < g_status_count; ++i) {
|
||||||
if (device_id.equalsIgnoreCase(g_statuses[i].last_data.device_id)) {
|
if (device_id.equalsIgnoreCase(g_statuses[i].last_data.device_id)) {
|
||||||
String html = html_header("Sender " + device_id);
|
String html = html_header("Sender " + device_id);
|
||||||
@@ -376,6 +484,7 @@ static void handle_sender() {
|
|||||||
html += "<canvas id='hist_canvas' width='320' height='140' style='width:100%;max-width:520px;border:1px solid #ccc;margin-top:6px;'></canvas>";
|
html += "<canvas id='hist_canvas' width='320' height='140' style='width:100%;max-width:520px;border:1px solid #ccc;margin-top:6px;'></canvas>";
|
||||||
html += "</div>";
|
html += "</div>";
|
||||||
html += "<script>";
|
html += "<script>";
|
||||||
|
html += "const deviceId='" + device_id_url + "';";
|
||||||
html += "let histTimer=null;";
|
html += "let histTimer=null;";
|
||||||
html += "function histStatus(msg){document.getElementById('hist_status').textContent=msg;}";
|
html += "function histStatus(msg){document.getElementById('hist_status').textContent=msg;}";
|
||||||
html += "function drawHistory(){";
|
html += "function drawHistory(){";
|
||||||
@@ -383,7 +492,7 @@ static void handle_sender() {
|
|||||||
html += "const res=document.getElementById('hist_res').value;";
|
html += "const res=document.getElementById('hist_res').value;";
|
||||||
html += "const mode=document.getElementById('hist_mode').value;";
|
html += "const mode=document.getElementById('hist_mode').value;";
|
||||||
html += "histStatus('Starting...');";
|
html += "histStatus('Starting...');";
|
||||||
html += "fetch(`/history/start?device_id=" + device_id + "&days=${days}&res=${res}&mode=${mode}`)";
|
html += "fetch(`/history/start?device_id=${deviceId}&days=${days}&res=${res}&mode=${mode}`)";
|
||||||
html += ".then(r=>r.json()).then(j=>{";
|
html += ".then(r=>r.json()).then(j=>{";
|
||||||
html += "if(!j.ok){histStatus('Error: '+(j.error||'failed'));return;}";
|
html += "if(!j.ok){histStatus('Error: '+(j.error||'failed'));return;}";
|
||||||
html += "if(histTimer){clearInterval(histTimer);}";
|
html += "if(histTimer){clearInterval(histTimer);}";
|
||||||
@@ -392,7 +501,7 @@ static void handle_sender() {
|
|||||||
html += "});";
|
html += "});";
|
||||||
html += "}";
|
html += "}";
|
||||||
html += "function fetchHistory(){";
|
html += "function fetchHistory(){";
|
||||||
html += "fetch(`/history/data?device_id=" + device_id + "`).then(r=>r.json()).then(j=>{";
|
html += "fetch(`/history/data?device_id=${deviceId}`).then(r=>r.json()).then(j=>{";
|
||||||
html += "if(!j.ready){histStatus(j.error?('Error: '+j.error):('Processing... '+(j.progress||0)+'%'));return;}";
|
html += "if(!j.ready){histStatus(j.error?('Error: '+j.error):('Processing... '+(j.progress||0)+'%'));return;}";
|
||||||
html += "if(histTimer){clearInterval(histTimer);histTimer=null;}";
|
html += "if(histTimer){clearInterval(histTimer);histTimer=null;}";
|
||||||
html += "renderChart(j.series);";
|
html += "renderChart(j.series);";
|
||||||
@@ -457,6 +566,9 @@ static void handle_sender() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void handle_manual() {
|
static void handle_manual() {
|
||||||
|
if (!ensure_auth()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
String html = html_header("DD3 Manual");
|
String html = html_header("DD3 Manual");
|
||||||
html += "<ul>";
|
html += "<ul>";
|
||||||
html += "<li>Energy: total kWh since meter start.</li>";
|
html += "<li>Energy: total kWh since meter start.</li>";
|
||||||
@@ -474,6 +586,9 @@ static void handle_manual() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void handle_history_start() {
|
static void handle_history_start() {
|
||||||
|
if (!ensure_auth()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!sd_logger_is_ready()) {
|
if (!sd_logger_is_ready()) {
|
||||||
server.send(200, "application/json", "{\"ok\":false,\"error\":\"sd_not_ready\"}");
|
server.send(200, "application/json", "{\"ok\":false,\"error\":\"sd_not_ready\"}");
|
||||||
return;
|
return;
|
||||||
@@ -534,6 +649,9 @@ static void handle_history_start() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void handle_history_data() {
|
static void handle_history_data() {
|
||||||
|
if (!ensure_auth()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
String device_id = server.arg("device_id");
|
String device_id = server.arg("device_id");
|
||||||
if (!g_history.bins || device_id.length() == 0 || device_id != g_history.device_id) {
|
if (!g_history.bins || device_id.length() == 0 || device_id != g_history.device_id) {
|
||||||
server.send(200, "application/json", "{\"ready\":false,\"error\":\"no_job\"}");
|
server.send(200, "application/json", "{\"ready\":false,\"error\":\"no_job\"}");
|
||||||
@@ -575,15 +693,19 @@ static void handle_history_data() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void handle_sd_download() {
|
static void handle_sd_download() {
|
||||||
|
if (!ensure_auth()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!sd_logger_is_ready()) {
|
if (!sd_logger_is_ready()) {
|
||||||
server.send(404, "text/plain", "SD not ready");
|
server.send(404, "text/plain", "SD not ready");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String path = server.arg("path");
|
String path = server.arg("path");
|
||||||
if (path.startsWith("dd3/")) {
|
String error;
|
||||||
path = "/" + path;
|
if (!sanitize_sd_download_path(path, error)) {
|
||||||
|
if (SERIAL_DEBUG_MODE) {
|
||||||
|
Serial.printf("sd: reject path '%s' reason=%s\n", path.c_str(), error.c_str());
|
||||||
}
|
}
|
||||||
if (!path.startsWith("/dd3/")) {
|
|
||||||
server.send(400, "text/plain", "Invalid path");
|
server.send(400, "text/plain", "Invalid path");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -618,6 +740,8 @@ static void handle_sd_download() {
|
|||||||
|
|
||||||
void web_server_set_config(const WifiMqttConfig &config) {
|
void web_server_set_config(const WifiMqttConfig &config) {
|
||||||
g_config = config;
|
g_config = config;
|
||||||
|
g_web_user = config.web_user;
|
||||||
|
g_web_pass = config.web_pass;
|
||||||
}
|
}
|
||||||
|
|
||||||
void web_server_set_sender_faults(const FaultCounters *faults, const FaultType *last_errors) {
|
void web_server_set_sender_faults(const FaultCounters *faults, const FaultType *last_errors) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "wifi_manager.h"
|
#include "wifi_manager.h"
|
||||||
|
#include "config.h"
|
||||||
#include <WiFi.h>
|
#include <WiFi.h>
|
||||||
#include <esp_wifi.h>
|
#include <esp_wifi.h>
|
||||||
|
|
||||||
@@ -10,9 +11,6 @@ void wifi_manager_init() {
|
|||||||
|
|
||||||
bool wifi_load_config(WifiMqttConfig &config) {
|
bool wifi_load_config(WifiMqttConfig &config) {
|
||||||
config.valid = prefs.getBool("valid", false);
|
config.valid = prefs.getBool("valid", false);
|
||||||
if (!config.valid) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
config.ssid = prefs.getString("ssid", "");
|
config.ssid = prefs.getString("ssid", "");
|
||||||
config.password = prefs.getString("pass", "");
|
config.password = prefs.getString("pass", "");
|
||||||
config.mqtt_host = prefs.getString("mqhost", "");
|
config.mqtt_host = prefs.getString("mqhost", "");
|
||||||
@@ -21,6 +19,11 @@ bool wifi_load_config(WifiMqttConfig &config) {
|
|||||||
config.mqtt_pass = prefs.getString("mqpass", "");
|
config.mqtt_pass = prefs.getString("mqpass", "");
|
||||||
config.ntp_server_1 = prefs.getString("ntp1", "pool.ntp.org");
|
config.ntp_server_1 = prefs.getString("ntp1", "pool.ntp.org");
|
||||||
config.ntp_server_2 = prefs.getString("ntp2", "time.nist.gov");
|
config.ntp_server_2 = prefs.getString("ntp2", "time.nist.gov");
|
||||||
|
config.web_user = prefs.getString("webuser", WEB_AUTH_DEFAULT_USER);
|
||||||
|
config.web_pass = prefs.getString("webpass", WEB_AUTH_DEFAULT_PASS);
|
||||||
|
if (!config.valid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return config.ssid.length() > 0 && config.mqtt_host.length() > 0;
|
return config.ssid.length() > 0 && config.mqtt_host.length() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +37,8 @@ bool wifi_save_config(const WifiMqttConfig &config) {
|
|||||||
prefs.putString("mqpass", config.mqtt_pass);
|
prefs.putString("mqpass", config.mqtt_pass);
|
||||||
prefs.putString("ntp1", config.ntp_server_1);
|
prefs.putString("ntp1", config.ntp_server_1);
|
||||||
prefs.putString("ntp2", config.ntp_server_2);
|
prefs.putString("ntp2", config.ntp_server_2);
|
||||||
|
prefs.putString("webuser", config.web_user);
|
||||||
|
prefs.putString("webpass", config.web_pass);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
21
test/test_html_escape/test_html_escape.cpp
Normal file
21
test/test_html_escape/test_html_escape.cpp
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#include <Arduino.h>
|
||||||
|
#include <unity.h>
|
||||||
|
#include "html_util.h"
|
||||||
|
|
||||||
|
static void test_html_escape_basic() {
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", html_escape("").c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("plain", html_escape("plain").c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("a&b", html_escape("a&b").c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("<tag>", html_escape("<tag>").c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING(""hi"", html_escape("\"hi\"").c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("it's", html_escape("it's").c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("&<>"'", html_escape("&<>\"'").c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
UNITY_BEGIN();
|
||||||
|
RUN_TEST(test_html_escape_basic);
|
||||||
|
UNITY_END();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {}
|
||||||
Reference in New Issue
Block a user