#include "app.h" #include 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()) 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()) return; String body = requestBody(); 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()) 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() { 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()) return; sendJson(200, jsonOk("\"certificate\":\"" + jsonEscape(prefString("cert", "")) + "\"")); } void handleCertPost() { if (!authorize()) return; String cert = jsonStringValue(requestBody(), "certificate"); prefs.putString("cert", cert); appendLog(LOG_SECURITY_AUDIT, "HTTPS certificate material saved"); sendJson(200, jsonOk("\"note\":\"certificate is stored for applications that enable TLS termination\"")); }