Files
PlatformIO_Esp32C3Boilerplate/src/handlers/handlers_admin.cpp

425 lines
15 KiB
C++
Raw Normal View History

2026-06-27 20:00:17 -05:00
#include "app.h"
#include <LittleFS.h>
2026-06-27 20:00:17 -05:00
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() {
2026-06-27 20:00:17 -05:00
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() {
2026-06-27 20:00:17 -05:00
if (!authorize()) return;
String body = requestBody();
2026-06-27 20:00:17 -05:00
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);
2026-06-27 20:00:17 -05:00
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);
2026-06-27 20:00:17 -05:00
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();
2026-06-27 20:00:17 -05:00
appendLog(LOG_SECURITY_AUDIT, "settings updated");
sendJson(200, jsonOk());
}
void handleLogsGet() {
2026-06-27 20:00:17 -05:00
if (!authorize()) return;
File f = LittleFS.open("/logs.txt", "r");
String logs = f ? f.readString() : "";
if (f) f.close();
sendJson(200, jsonOk("\"logs\":\"" + jsonEscape(logs) + "\""));
}
void handleLogsClear() {
2026-06-27 20:00:17 -05:00
if (!authorize()) return;
LittleFS.remove("/logs.txt");
2026-06-27 20:00:17 -05:00
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() {
2026-06-27 20:00:17 -05:00
if (!authorize()) return;
sendJson(200, jsonOk("\"certificate\":\"" + jsonEscape(prefString("cert", "")) + "\""));
}
void handleCertPost() {
2026-06-27 20:00:17 -05:00
if (!authorize()) return;
String cert = jsonStringValue(requestBody(), "certificate");
prefs.putString("cert", cert);
2026-06-27 20:00:17 -05:00
appendLog(LOG_SECURITY_AUDIT, "HTTPS certificate material saved");
sendJson(200, jsonOk("\"note\":\"certificate is stored for applications that enable TLS termination\""));
}