More work on the ESP32C3 Boilerplate
This commit is contained in:
@@ -1,32 +1,88 @@
|
||||
#include "../app.h"
|
||||
#include "app.h"
|
||||
|
||||
#include <LittleFS.h>
|
||||
|
||||
static String networkSettingsJson(bool includeStatus) {
|
||||
String out = "\"hostname\":\"" + jsonEscape(networkHostname) + "\",\"dhcp\":" + String(networkDhcp ? "true" : "false") + ",\"ip\":\"" + jsonEscape(networkIp) + "\",\"gateway\":\"" + jsonEscape(networkGateway) + "\",\"subnet\":\"" + jsonEscape(networkSubnet) + "\",\"dns1\":\"" + jsonEscape(networkDns1) + "\",\"dns2\":\"" + jsonEscape(networkDns2) + "\"";
|
||||
if (includeStatus) out += ",\"defaultHostname\":\"" + jsonEscape(defaultDeviceName()) + "\",\"currentIp\":\"" + WiFi.localIP().toString() + "\"";
|
||||
return out;
|
||||
}
|
||||
|
||||
static bool validHostnameValue(const String &hostname) {
|
||||
if (!hostname.length()) return true;
|
||||
if (hostname.length() > 31) return false;
|
||||
if (hostname.startsWith("-") || hostname.endsWith("-")) return false;
|
||||
for (size_t i = 0; i < hostname.length(); i++) {
|
||||
char c = hostname[i];
|
||||
if (!(isalnum(c) || c == '-')) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool validIpValue(const String &value) {
|
||||
IPAddress ip;
|
||||
return value.length() && ip.fromString(value);
|
||||
}
|
||||
|
||||
void handleSettingsGet() {
|
||||
if (!authorize("/api/settings", "GET")) return;
|
||||
String out = "\"settings\":{\"logLevel\":" + String(currentLogLevel) + ",\"maxLogBytes\":" + String(maxLogBytes) + ",\"updateUrl\":\"" + jsonEscape(updateUrl) + "\",\"ledInverted\":" + String(ledInverted ? "true" : "false") + ",\"ledBrightness\":" + String(ledBrightness) + "}";
|
||||
if (!authorize()) return;
|
||||
String out = "\"settings\":{\"logLevel\":" + String(currentLogLevel) + ",\"maxLogBytes\":" + String(maxLogBytes) + ",\"updateUrl\":\"" + jsonEscape(updateUrl) + "\",\"ledInverted\":" + String(ledInverted ? "true" : "false") + ",\"ledBrightness\":" + String(ledBrightness) + "," + networkSettingsJson(true) + "}";
|
||||
sendJson(200, jsonOk(out));
|
||||
}
|
||||
|
||||
void handleSettingsPost() {
|
||||
if (!authorize("/api/settings", "POST")) return;
|
||||
if (!authorize()) return;
|
||||
String body = requestBody();
|
||||
currentLogLevel = (LogLevel)constrain(jsonIntValue(body, "logLevel", currentLogLevel), 0, 3);
|
||||
String nextHostname = jsonStringValue(body, "hostname", networkHostname);
|
||||
nextHostname.trim();
|
||||
bool nextDhcp = jsonBoolValue(body, "dhcp", networkDhcp);
|
||||
String nextIp = jsonStringValue(body, "ip", networkIp);
|
||||
String nextGateway = jsonStringValue(body, "gateway", networkGateway);
|
||||
String nextSubnet = jsonStringValue(body, "subnet", networkSubnet);
|
||||
String nextDns1 = jsonStringValue(body, "dns1", networkDns1);
|
||||
String nextDns2 = jsonStringValue(body, "dns2", networkDns2);
|
||||
nextIp.trim();
|
||||
nextGateway.trim();
|
||||
nextSubnet.trim();
|
||||
nextDns1.trim();
|
||||
nextDns2.trim();
|
||||
if (!validHostnameValue(nextHostname)) return sendJson(400, jsonError("Hostname must be 1-31 letters, digits, or hyphens, and cannot start or end with a hyphen"));
|
||||
if (!nextDhcp) {
|
||||
if (!validIpValue(nextIp) || !validIpValue(nextGateway) || !validIpValue(nextSubnet)) return sendJson(400, jsonError("Static IP, gateway, and subnet must be valid IPv4 addresses"));
|
||||
if (nextDns1.length() && !validIpValue(nextDns1)) return sendJson(400, jsonError("DNS 1 must be a valid IPv4 address"));
|
||||
if (nextDns2.length() && !validIpValue(nextDns2)) return sendJson(400, jsonError("DNS 2 must be a valid IPv4 address"));
|
||||
}
|
||||
currentLogLevel = (LogLevel)constrain(jsonIntValue(body, "logLevel", currentLogLevel), 0, 4);
|
||||
maxLogBytes = constrain(jsonIntValue(body, "maxLogBytes", maxLogBytes), 4096, 128 * 1024);
|
||||
ledInverted = jsonBoolValue(body, "ledInverted", ledInverted);
|
||||
ledBrightness = constrain(jsonIntValue(body, "ledBrightness", ledBrightness), 0, 100);
|
||||
updateUrl = jsonStringValue(body, "updateUrl", updateUrl);
|
||||
networkHostname = nextHostname;
|
||||
networkDhcp = nextDhcp;
|
||||
networkIp = nextIp;
|
||||
networkGateway = nextGateway;
|
||||
networkSubnet = nextSubnet;
|
||||
networkDns1 = nextDns1;
|
||||
networkDns2 = nextDns2;
|
||||
prefs.putUChar("logLevel", currentLogLevel);
|
||||
prefs.putUInt("logMax", maxLogBytes);
|
||||
prefs.putBool("ledInv", ledInverted);
|
||||
prefs.putUChar("ledBright", ledBrightness);
|
||||
prefs.putString("updateUrl", updateUrl);
|
||||
prefs.putString("netHost", networkHostname);
|
||||
prefs.putBool("netDhcp", networkDhcp);
|
||||
prefs.putString("netIp", networkIp);
|
||||
prefs.putString("netGw", networkGateway);
|
||||
prefs.putString("netMask", networkSubnet);
|
||||
prefs.putString("netDns1", networkDns1);
|
||||
prefs.putString("netDns2", networkDns2);
|
||||
applyLed();
|
||||
appendLog(LOG_SECURITY_AUDIT, "settings updated");
|
||||
sendJson(200, jsonOk());
|
||||
}
|
||||
|
||||
void handleLogsGet() {
|
||||
if (!authorize("/api/logs", "GET")) return;
|
||||
if (!authorize()) return;
|
||||
File f = LittleFS.open("/logs.txt", "r");
|
||||
String logs = f ? f.readString() : "";
|
||||
if (f) f.close();
|
||||
@@ -34,20 +90,335 @@ void handleLogsGet() {
|
||||
}
|
||||
|
||||
void handleLogsClear() {
|
||||
if (!authorize("/api/logs/clear", "POST")) return;
|
||||
if (!authorize()) return;
|
||||
LittleFS.remove("/logs.txt");
|
||||
appendLog(LOG_SECURITY_AUDIT, "logs cleared");
|
||||
sendJson(200, jsonOk());
|
||||
}
|
||||
|
||||
static bool validFsPath(const String &path) {
|
||||
return path.length() && path.startsWith("/") && path.indexOf("..") < 0;
|
||||
}
|
||||
|
||||
static String fsPathArg(const String &fallback = "/") {
|
||||
String path = server.arg("path");
|
||||
if (!path.length()) path = fallback;
|
||||
return path;
|
||||
}
|
||||
|
||||
static void appendFileJson(String &out, bool &first, File &file) {
|
||||
String path = file.name();
|
||||
if (!path.startsWith("/")) path = "/" + path;
|
||||
if (!first) out += ",";
|
||||
out += "{\"name\":\"" + jsonEscape(path.substring(path.lastIndexOf('/') + 1)) + "\",\"path\":\"" + jsonEscape(path) + "\",\"size\":" + String(file.size()) + "}";
|
||||
first = false;
|
||||
}
|
||||
|
||||
static void appendFilesJson(String &out, bool &first, File &dir) {
|
||||
File file = dir.openNextFile();
|
||||
while (file) {
|
||||
if (file.isDirectory()) {
|
||||
appendFilesJson(out, first, file);
|
||||
} else {
|
||||
appendFileJson(out, first, file);
|
||||
}
|
||||
file.close();
|
||||
file = dir.openNextFile();
|
||||
}
|
||||
}
|
||||
|
||||
void handleFilesList() {
|
||||
if (!authorize()) return;
|
||||
File root = LittleFS.open("/", "r");
|
||||
if (!root) return sendJson(500, jsonError("Filesystem root not available"));
|
||||
|
||||
String out = "\"files\":[";
|
||||
bool first = true;
|
||||
appendFilesJson(out, first, root);
|
||||
root.close();
|
||||
out += "]";
|
||||
sendJson(200, jsonOk(out));
|
||||
}
|
||||
|
||||
void handleFileDownload() {
|
||||
if (!authorize()) return;
|
||||
String path = fsPathArg("");
|
||||
if (!validFsPath(path)) return sendJson(400, jsonError("Invalid path"));
|
||||
File file = LittleFS.open(path, "r");
|
||||
if (!file || file.isDirectory()) return sendJson(404, jsonError("File not found"));
|
||||
String name = path.substring(path.lastIndexOf('/') + 1);
|
||||
server.sendHeader("Content-Disposition", "attachment; filename=\"" + name + "\"");
|
||||
server.streamFile(file, "application/octet-stream");
|
||||
file.close();
|
||||
}
|
||||
|
||||
void handleFactoryReset() {
|
||||
if (!authorize()) return;
|
||||
factoryReset();
|
||||
sendJson(200, jsonOk("\"restart\":true"));
|
||||
delay(500);
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
static String apiPrefKey(const String &path, const String &method) {
|
||||
String key = "acl";
|
||||
key += method[0];
|
||||
for (size_t i = 0; i < path.length(); i++) {
|
||||
char c = path[i];
|
||||
if (isalnum(c)) key += c;
|
||||
}
|
||||
return key.substring(0, 15);
|
||||
}
|
||||
|
||||
static String jsonArrayStringList(const String &items) {
|
||||
String out = "[";
|
||||
int start = 0;
|
||||
bool first = true;
|
||||
while (start <= (int)items.length()) {
|
||||
int end = items.indexOf('|', start);
|
||||
if (end < 0) end = items.length();
|
||||
String item = items.substring(start, end);
|
||||
if (item.length()) {
|
||||
if (!first) out += ",";
|
||||
out += "\"" + jsonEscape(item) + "\"";
|
||||
first = false;
|
||||
}
|
||||
start = end + 1;
|
||||
if (!items.length()) break;
|
||||
}
|
||||
out += "]";
|
||||
return out;
|
||||
}
|
||||
|
||||
static String extractJsonArray(const String &json, const char *key) {
|
||||
String needle = "\"" + String(key) + "\"";
|
||||
int p = json.indexOf(needle);
|
||||
if (p < 0) return "";
|
||||
p = json.indexOf('[', p + needle.length());
|
||||
if (p < 0) return "";
|
||||
int start = p;
|
||||
int depth = 0;
|
||||
bool inString = false;
|
||||
bool esc = false;
|
||||
for (; p < (int)json.length(); p++) {
|
||||
char c = json[p];
|
||||
if (esc) {
|
||||
esc = false;
|
||||
} else if (c == '\\') {
|
||||
esc = inString;
|
||||
} else if (c == '"') {
|
||||
inString = !inString;
|
||||
} else if (!inString && c == '[') {
|
||||
depth++;
|
||||
} else if (!inString && c == ']') {
|
||||
depth--;
|
||||
if (depth == 0) return json.substring(start, p + 1);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
static String objectAt(const String &array, int &pos) {
|
||||
while (pos < (int)array.length() && array[pos] != '{') pos++;
|
||||
if (pos >= (int)array.length()) return "";
|
||||
int start = pos;
|
||||
int depth = 0;
|
||||
bool inString = false;
|
||||
bool esc = false;
|
||||
for (; pos < (int)array.length(); pos++) {
|
||||
char c = array[pos];
|
||||
if (esc) {
|
||||
esc = false;
|
||||
} else if (c == '\\') {
|
||||
esc = inString;
|
||||
} else if (c == '"') {
|
||||
inString = !inString;
|
||||
} else if (!inString && c == '{') {
|
||||
depth++;
|
||||
} else if (!inString && c == '}') {
|
||||
depth--;
|
||||
if (depth == 0) return array.substring(start, ++pos);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
static String stringArrayToRoles(const String &array) {
|
||||
String out;
|
||||
int p = 0;
|
||||
while (p < (int)array.length()) {
|
||||
while (p < (int)array.length() && array[p] != '"') p++;
|
||||
if (p >= (int)array.length()) break;
|
||||
p++;
|
||||
String item;
|
||||
bool esc = false;
|
||||
while (p < (int)array.length()) {
|
||||
char c = array[p++];
|
||||
if (esc) {
|
||||
item += c == 'n' ? '\n' : c == 'r' ? '\r' : c;
|
||||
esc = false;
|
||||
} else if (c == '\\') {
|
||||
esc = true;
|
||||
} else if (c == '"') {
|
||||
break;
|
||||
} else {
|
||||
item += c;
|
||||
}
|
||||
}
|
||||
if (item.length()) {
|
||||
if (out.length()) out += "|";
|
||||
out += item;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static String cleanCustomRolesBackup(const String &roles) {
|
||||
String out;
|
||||
int start = 0;
|
||||
while (start <= (int)roles.length()) {
|
||||
int end = roles.indexOf('|', start);
|
||||
if (end < 0) end = roles.length();
|
||||
String role = roles.substring(start, end);
|
||||
if (validName(role) && !isSystemRole(role) && !hasRole(out, role)) {
|
||||
if (out.length()) out += "|";
|
||||
out += role;
|
||||
}
|
||||
start = end + 1;
|
||||
if (!roles.length()) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void handleConfigExport() {
|
||||
if (!authorize()) return;
|
||||
User users[MAX_USERS];
|
||||
size_t userCount = parseUsers(users, MAX_USERS);
|
||||
|
||||
String out = "{";
|
||||
out += "\"version\":1";
|
||||
out += ",\"settings\":{\"logLevel\":" + String(currentLogLevel) + ",\"maxLogBytes\":" + String(maxLogBytes) + ",\"updateUrl\":\"" + jsonEscape(updateUrl) + "\",\"ledInverted\":" + String(ledInverted ? "true" : "false") + ",\"ledBrightness\":" + String(ledBrightness) + "," + networkSettingsJson(false) + "}";
|
||||
out += ",\"customRoles\":" + jsonArrayStringList(prefString("roles", ""));
|
||||
out += ",\"users\":[";
|
||||
for (size_t i = 0; i < userCount; i++) {
|
||||
if (i) out += ",";
|
||||
out += "{\"username\":\"" + jsonEscape(users[i].name) + "\",\"passwordHash\":\"" + jsonEscape(users[i].passwordHash) + "\",\"roles\":\"" + jsonEscape(users[i].roles) + "\",\"active\":" + String(users[i].active ? "true" : "false") + "}";
|
||||
}
|
||||
out += "],\"apiAcls\":[";
|
||||
for (size_t i = 0; i < API_DEF_COUNT + CUSTOM_API_DEF_COUNT; i++) {
|
||||
ApiDef *api = i < API_DEF_COUNT ? &apiDefs[i] : &customApiDefs[i - API_DEF_COUNT];
|
||||
if (i) out += ",";
|
||||
out += "{\"path\":\"" + jsonEscape(api->path) + "\",\"method\":\"" + jsonEscape(api->method) + "\",\"role\":\"" + jsonEscape(configuredRole(api)) + "\"}";
|
||||
}
|
||||
out += "],\"certificate\":\"" + jsonEscape(prefString("cert", "")) + "\"}";
|
||||
|
||||
server.sendHeader("Cache-Control", "no-store");
|
||||
server.sendHeader("Content-Disposition", "attachment; filename=\"config-backup.json\"");
|
||||
server.send(200, "application/json", out);
|
||||
}
|
||||
|
||||
void handleConfigImport() {
|
||||
if (!authorize()) return;
|
||||
String body = requestBody();
|
||||
if (!body.length()) return sendJson(400, jsonError("Configuration JSON is required"));
|
||||
|
||||
String customRoles = cleanCustomRolesBackup(stringArrayToRoles(extractJsonArray(body, "customRoles")));
|
||||
prefs.putString("roles", customRoles);
|
||||
|
||||
User users[MAX_USERS];
|
||||
size_t userCount = 0;
|
||||
String usersArray = extractJsonArray(body, "users");
|
||||
int pos = 0;
|
||||
while (userCount < MAX_USERS) {
|
||||
String item = objectAt(usersArray, pos);
|
||||
if (!item.length()) break;
|
||||
String username = jsonStringValue(item, "username");
|
||||
String password = jsonStringValue(item, "passwordHash");
|
||||
if (!validName(username) || !password.length()) return sendJson(400, jsonError("Invalid user in backup"));
|
||||
users[userCount].name = username;
|
||||
users[userCount].passwordHash = password;
|
||||
users[userCount].roles = cleanRoles(jsonStringValue(item, "roles", "WebUIConnect"));
|
||||
users[userCount].active = jsonBoolValue(item, "active", true);
|
||||
userCount++;
|
||||
}
|
||||
if (!userCount) return sendJson(400, jsonError("Backup must contain at least one user"));
|
||||
bool hasActiveSysadmin = false;
|
||||
for (size_t i = 0; i < userCount; i++) {
|
||||
if (users[i].active && hasRole(users[i].roles, "Sysadmin")) hasActiveSysadmin = true;
|
||||
}
|
||||
if (!hasActiveSysadmin) return sendJson(400, jsonError("Backup must contain an active Sysadmin user"));
|
||||
saveUsers(users, userCount);
|
||||
|
||||
currentLogLevel = (LogLevel)constrain(jsonIntValue(body, "logLevel", currentLogLevel), 0, 4);
|
||||
maxLogBytes = constrain(jsonIntValue(body, "maxLogBytes", maxLogBytes), 4096, 128 * 1024);
|
||||
updateUrl = jsonStringValue(body, "updateUrl", updateUrl);
|
||||
ledInverted = jsonBoolValue(body, "ledInverted", ledInverted);
|
||||
ledBrightness = constrain(jsonIntValue(body, "ledBrightness", ledBrightness), 0, 100);
|
||||
String nextHostname = jsonStringValue(body, "hostname", networkHostname);
|
||||
nextHostname.trim();
|
||||
bool nextDhcp = jsonBoolValue(body, "dhcp", networkDhcp);
|
||||
String nextIp = jsonStringValue(body, "ip", networkIp);
|
||||
String nextGateway = jsonStringValue(body, "gateway", networkGateway);
|
||||
String nextSubnet = jsonStringValue(body, "subnet", networkSubnet);
|
||||
String nextDns1 = jsonStringValue(body, "dns1", networkDns1);
|
||||
String nextDns2 = jsonStringValue(body, "dns2", networkDns2);
|
||||
nextIp.trim();
|
||||
nextGateway.trim();
|
||||
nextSubnet.trim();
|
||||
nextDns1.trim();
|
||||
nextDns2.trim();
|
||||
if (validHostnameValue(nextHostname)) networkHostname = nextHostname;
|
||||
networkDhcp = nextDhcp;
|
||||
if (networkDhcp || (validIpValue(nextIp) && validIpValue(nextGateway) && validIpValue(nextSubnet))) {
|
||||
networkIp = nextIp;
|
||||
networkGateway = nextGateway;
|
||||
networkSubnet = nextSubnet;
|
||||
if (!nextDns1.length() || validIpValue(nextDns1)) networkDns1 = nextDns1;
|
||||
if (!nextDns2.length() || validIpValue(nextDns2)) networkDns2 = nextDns2;
|
||||
}
|
||||
prefs.putUChar("logLevel", currentLogLevel);
|
||||
prefs.putUInt("logMax", maxLogBytes);
|
||||
prefs.putString("updateUrl", updateUrl);
|
||||
prefs.putBool("ledInv", ledInverted);
|
||||
prefs.putUChar("ledBright", ledBrightness);
|
||||
prefs.putString("netHost", networkHostname);
|
||||
prefs.putBool("netDhcp", networkDhcp);
|
||||
prefs.putString("netIp", networkIp);
|
||||
prefs.putString("netGw", networkGateway);
|
||||
prefs.putString("netMask", networkSubnet);
|
||||
prefs.putString("netDns1", networkDns1);
|
||||
prefs.putString("netDns2", networkDns2);
|
||||
prefs.putString("cert", jsonStringValue(body, "certificate", ""));
|
||||
|
||||
String aclsArray = extractJsonArray(body, "apiAcls");
|
||||
pos = 0;
|
||||
while (true) {
|
||||
String item = objectAt(aclsArray, pos);
|
||||
if (!item.length()) break;
|
||||
String path = jsonStringValue(item, "path");
|
||||
String method = jsonStringValue(item, "method", "GET");
|
||||
method.toUpperCase();
|
||||
String role = jsonStringValue(item, "role", "PUBLIC");
|
||||
ApiDef *api = findApi(path, method);
|
||||
if (api && isKnownRole(role)) {
|
||||
prefs.putString(apiPrefKey(path, method).c_str(), role);
|
||||
}
|
||||
}
|
||||
|
||||
applyLed();
|
||||
appendLog(LOG_SECURITY_AUDIT, "configuration restored from backup");
|
||||
sendJson(200, jsonOk());
|
||||
}
|
||||
|
||||
void handleCertGet() {
|
||||
if (!authorize("/api/cert", "GET")) return;
|
||||
if (!authorize()) return;
|
||||
sendJson(200, jsonOk("\"certificate\":\"" + jsonEscape(prefString("cert", "")) + "\""));
|
||||
}
|
||||
|
||||
void handleCertPost() {
|
||||
if (!authorize("/api/cert", "POST")) return;
|
||||
if (!authorize()) return;
|
||||
String cert = jsonStringValue(requestBody(), "certificate");
|
||||
prefs.putString("cert", cert);
|
||||
appendLog(LOG_INFO, "HTTPS certificate material saved");
|
||||
appendLog(LOG_SECURITY_AUDIT, "HTTPS certificate material saved");
|
||||
sendJson(200, jsonOk("\"note\":\"certificate is stored for applications that enable TLS termination\""));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user