add bed cooler project.
This commit is contained in:
33
src/config/api_definitions.cpp
Normal file
33
src/config/api_definitions.cpp
Normal file
@@ -0,0 +1,33 @@
|
||||
#include "app.h"
|
||||
|
||||
ApiDef apiDefs[] = {
|
||||
{"/api/wifi/scan", "GET", "Sysadmin", false, handleWifiScan, nullptr},
|
||||
{"/api/setup", "POST", "Sysadmin", false, handleSetupSubmit, nullptr},
|
||||
{"/api/login", "POST", "", true, handleLogin, nullptr},
|
||||
{"/api/logout", "POST", "WebUIConnect", false, handleLogout, nullptr},
|
||||
{"/api/me", "GET", "WebUIConnect", false, handleMe, nullptr},
|
||||
{"/api/apis", "GET", "Sysadmin", false, handleApisGet, nullptr},
|
||||
{"/api/apis", "POST", "Sysadmin", false, handleApisPost, nullptr},
|
||||
{"/api/users", "GET", "UserAdmin", false, handleUsersGet, nullptr},
|
||||
{"/api/users", "POST", "UserAdmin", false, handleUsersPost, nullptr},
|
||||
{"/api/roles", "GET", "UserAdmin", false, handleRolesGet, nullptr},
|
||||
{"/api/roles", "POST", "UserAdmin", false, handleRolesPost, nullptr},
|
||||
{"/api/roles", "DELETE", "UserAdmin", false, handleRolesDelete, nullptr},
|
||||
{"/api/password", "POST", "WebUIConnect", false, handlePassword, nullptr},
|
||||
{"/api/settings", "GET", "Sysadmin", false, handleSettingsGet, nullptr},
|
||||
{"/api/settings", "POST", "Sysadmin", false, handleSettingsPost, nullptr},
|
||||
{"/api/logs", "GET", "Debugger", false, handleLogsGet, nullptr},
|
||||
{"/api/logs/clear", "POST", "Debugger", false, handleLogsClear, nullptr},
|
||||
{"/api/files", "GET", "Debugger", false, handleFilesList, nullptr},
|
||||
{"/api/files/download", "GET", "Debugger", false, handleFileDownload, nullptr},
|
||||
{"/api/factory-reset", "POST", "Sysadmin", false, handleFactoryReset, nullptr},
|
||||
{"/api/config/export", "GET", "Sysadmin", false, handleConfigExport, nullptr},
|
||||
{"/api/config/import", "POST", "Sysadmin", false, handleConfigImport, nullptr},
|
||||
{"/api/ota/check", "POST", "Sysadmin", false, handleOtaCheck, nullptr},
|
||||
{"/api/ota/run", "POST", "Sysadmin", false, handleOtaRun, nullptr},
|
||||
{"/api/update", "POST", "Sysadmin", false, handleUpdateUploadDone, handleUpdateUploadChunk},
|
||||
{"/api/cert", "GET", "Sysadmin", false, handleCertGet, nullptr},
|
||||
{"/api/cert", "POST", "Sysadmin", false, handleCertPost, nullptr},
|
||||
};
|
||||
|
||||
const size_t API_DEF_COUNT = sizeof(apiDefs) / sizeof(apiDefs[0]);
|
||||
12
src/config/api_definitions_custom.cpp
Normal file
12
src/config/api_definitions_custom.cpp
Normal file
@@ -0,0 +1,12 @@
|
||||
#include "app.h"
|
||||
#include "custom_api.h"
|
||||
|
||||
ApiDef customApiDefs[] = {
|
||||
{"/api/ping", "GET", "", true, handlePing, nullptr},
|
||||
{"/api/add", "POST", "", true, handleAdd, nullptr},
|
||||
{"/api/led", "POST", "", true, handleLed, nullptr},
|
||||
{"/api/uptime", "GET", "WebUIConnect", false, handleUptime, nullptr},
|
||||
{"/api/uptime/events", "GET", "WebUIConnect", false, handleUptimeEvents, nullptr},
|
||||
};
|
||||
|
||||
const size_t CUSTOM_API_DEF_COUNT = sizeof(customApiDefs) / sizeof(customApiDefs[0]);
|
||||
295
src/core/auth.cpp
Normal file
295
src/core/auth.cpp
Normal file
@@ -0,0 +1,295 @@
|
||||
#include "app.h"
|
||||
|
||||
bool hasRole(const String &roles, const String &role) {
|
||||
if (role.length() == 0) return true;
|
||||
int start = 0;
|
||||
while (start <= (int)roles.length()) {
|
||||
int end = roles.indexOf('|', start);
|
||||
if (end < 0) end = roles.length();
|
||||
if (roles.substring(start, end) == role) return true;
|
||||
start = end + 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool validName(const String &s) {
|
||||
if (s.length() == 0 || s.length() > 31) return false;
|
||||
for (size_t i = 0; i < s.length(); i++) {
|
||||
char c = s[i];
|
||||
if (!(isalnum(c) || c == '_' || c == '-' || c == '.')) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
String cleanRoles(const String &roles) {
|
||||
String out;
|
||||
String known = allRoles();
|
||||
int start = 0;
|
||||
while (start <= (int)known.length()) {
|
||||
int end = known.indexOf('|', start);
|
||||
if (end < 0) end = known.length();
|
||||
String role = known.substring(start, end);
|
||||
if (role.length() && hasRole(roles, role)) {
|
||||
if (out.length()) out += "|";
|
||||
out += role;
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool isKnownRole(const String &role) {
|
||||
return role == "PUBLIC" || hasRole(allRoles(), role);
|
||||
}
|
||||
|
||||
bool isSystemRole(const String &role) {
|
||||
return role == "Sysadmin" || role == "UserAdmin" || role == "WebUIConnect" || role == "Debugger";
|
||||
}
|
||||
|
||||
String allRoles() {
|
||||
String roles = "Sysadmin|UserAdmin|WebUIConnect|Debugger";
|
||||
String custom = prefString("roles", "");
|
||||
int start = 0;
|
||||
while (start <= (int)custom.length()) {
|
||||
int end = custom.indexOf('|', start);
|
||||
if (end < 0) end = custom.length();
|
||||
String role = custom.substring(start, end);
|
||||
if (role.length() && !hasRole(roles, role)) roles += "|" + role;
|
||||
start = end + 1;
|
||||
if (!custom.length()) break;
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
String rolesJson() {
|
||||
String roles = allRoles();
|
||||
String out = "\"roles\":[";
|
||||
int start = 0;
|
||||
bool first = true;
|
||||
while (start <= (int)roles.length()) {
|
||||
int end = roles.indexOf('|', start);
|
||||
if (end < 0) end = roles.length();
|
||||
String role = roles.substring(start, end);
|
||||
if (role.length()) {
|
||||
if (!first) out += ",";
|
||||
out += "{\"name\":\"" + jsonEscape(role) + "\",\"system\":" + String(isSystemRole(role) ? "true" : "false") + "}";
|
||||
first = false;
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
out += "]";
|
||||
return out;
|
||||
}
|
||||
|
||||
bool addCustomRole(const String &role) {
|
||||
if (!validName(role) || isKnownRole(role)) return false;
|
||||
String custom = prefString("roles", "");
|
||||
if (custom.length()) custom += "|";
|
||||
custom += role;
|
||||
prefs.putString("roles", custom);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool deleteCustomRole(const String &role) {
|
||||
if (!validName(role) || isSystemRole(role) || !hasRole(prefString("roles", ""), role)) return false;
|
||||
String custom = prefString("roles", "");
|
||||
String kept;
|
||||
int start = 0;
|
||||
while (start <= (int)custom.length()) {
|
||||
int end = custom.indexOf('|', start);
|
||||
if (end < 0) end = custom.length();
|
||||
String item = custom.substring(start, end);
|
||||
if (item.length() && item != role) {
|
||||
if (kept.length()) kept += "|";
|
||||
kept += item;
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
prefs.putString("roles", kept);
|
||||
|
||||
User users[8];
|
||||
size_t count = parseUsers(users, MAX_USERS);
|
||||
for (size_t i = 0; i < count; i++) users[i].roles = cleanRoles(users[i].roles);
|
||||
saveUsers(users, count);
|
||||
|
||||
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 (configuredRole(api) == role) {
|
||||
String fallback = api->publicByDefault ? "PUBLIC" : api->defaultRole;
|
||||
String key = "acl";
|
||||
key += api->method[0];
|
||||
for (size_t j = 0; j < strlen(api->path); j++) {
|
||||
char c = api->path[j];
|
||||
if (isalnum(c)) key += c;
|
||||
}
|
||||
prefs.putString(key.substring(0, 15).c_str(), fallback);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static String defaultUsers() {
|
||||
return String(DEFAULT_ADMIN) + "\t" + passwordHash("") + "\tSysadmin|UserAdmin|WebUIConnect|Debugger\t1\n";
|
||||
}
|
||||
|
||||
static String usersText() {
|
||||
String text = prefString("users", "");
|
||||
return text.length() ? text : defaultUsers();
|
||||
}
|
||||
|
||||
size_t parseUsers(User *users, size_t maxUsers) {
|
||||
String text = usersText();
|
||||
size_t count = 0;
|
||||
int start = 0;
|
||||
while (start < (int)text.length() && count < maxUsers) {
|
||||
int end = text.indexOf('\n', start);
|
||||
if (end < 0) end = text.length();
|
||||
String line = text.substring(start, end);
|
||||
int a = line.indexOf('\t');
|
||||
int b = line.indexOf('\t', a + 1);
|
||||
int c = line.indexOf('\t', b + 1);
|
||||
if (a > 0 && b > a) {
|
||||
users[count].name = line.substring(0, a);
|
||||
users[count].passwordHash = line.substring(a + 1, b);
|
||||
users[count].roles = cleanRoles(c > b ? line.substring(b + 1, c) : line.substring(b + 1));
|
||||
users[count].active = c > b ? line.substring(c + 1).toInt() != 0 : true;
|
||||
count++;
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
void saveUsers(User *users, size_t count) {
|
||||
String text;
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
text += users[i].name + "\t" + users[i].passwordHash + "\t" + cleanRoles(users[i].roles) + "\t" + String(users[i].active ? "1" : "0") + "\n";
|
||||
}
|
||||
prefs.putString("users", text);
|
||||
}
|
||||
|
||||
bool findUser(const String &name, User &user) {
|
||||
User users[8];
|
||||
size_t count = parseUsers(users, MAX_USERS);
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
if (users[i].name == name) {
|
||||
user = users[i];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
String createToken(const User &user) {
|
||||
String value = sha256(String(esp_random(), HEX) + ":" + user.name + ":" + String(millis()));
|
||||
int slot = 0;
|
||||
uint32_t oldest = tokens[0].lastSeen;
|
||||
for (int i = 0; i < (int)MAX_TOKENS; i++) {
|
||||
if (!tokens[i].value.length()) {
|
||||
slot = i;
|
||||
break;
|
||||
}
|
||||
if (tokens[i].lastSeen < oldest) {
|
||||
oldest = tokens[i].lastSeen;
|
||||
slot = i;
|
||||
}
|
||||
}
|
||||
tokens[slot].value = value;
|
||||
tokens[slot].user = user.name;
|
||||
tokens[slot].roles = user.roles;
|
||||
tokens[slot].lastSeen = millis();
|
||||
return value;
|
||||
}
|
||||
|
||||
static String bearerToken() {
|
||||
String auth = server.header("Authorization");
|
||||
if (auth.startsWith("Bearer ")) return auth.substring(7);
|
||||
if (server.hasHeader("X-Auth-Token")) return server.header("X-Auth-Token");
|
||||
if (server.hasArg("token")) return server.arg("token");
|
||||
return "";
|
||||
}
|
||||
|
||||
static String requestMethodName();
|
||||
|
||||
Token *currentToken() {
|
||||
String value = bearerToken();
|
||||
if (!value.length()) return nullptr;
|
||||
for (size_t i = 0; i < MAX_TOKENS; i++) {
|
||||
if (tokens[i].value == value) {
|
||||
tokens[i].lastSeen = millis();
|
||||
return &tokens[i];
|
||||
}
|
||||
}
|
||||
appendLog(LOG_SECURITY_AUDIT, "invalid bearer token for " + requestMethodName() + " " + server.uri());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static String apiKey(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);
|
||||
}
|
||||
|
||||
ApiDef *findApi(const String &path, const String &method) {
|
||||
for (size_t i = 0; i < API_DEF_COUNT; i++) {
|
||||
if (path == apiDefs[i].path && method == apiDefs[i].method) return &apiDefs[i];
|
||||
}
|
||||
for (size_t i = 0; i < CUSTOM_API_DEF_COUNT; i++) {
|
||||
if (path == customApiDefs[i].path && method == customApiDefs[i].method) return &customApiDefs[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
String configuredRole(ApiDef *api) {
|
||||
if (!api) return "";
|
||||
String key = apiKey(api->path, api->method);
|
||||
return prefString(key.c_str(), api->publicByDefault ? "PUBLIC" : api->defaultRole);
|
||||
}
|
||||
|
||||
static String requestMethodName() {
|
||||
switch (server.method()) {
|
||||
case HTTP_GET:
|
||||
return "GET";
|
||||
case HTTP_POST:
|
||||
return "POST";
|
||||
case HTTP_DELETE:
|
||||
return "DELETE";
|
||||
case HTTP_PUT:
|
||||
return "PUT";
|
||||
case HTTP_PATCH:
|
||||
return "PATCH";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
bool authorize() {
|
||||
String path = server.uri();
|
||||
String method = requestMethodName();
|
||||
ApiDef *api = findApi(path, method);
|
||||
String required = configuredRole(api);
|
||||
if (required == "PUBLIC" || (api && api->publicByDefault && !required.length())) return true;
|
||||
Token *token = currentToken();
|
||||
if (!token) {
|
||||
if (!bearerToken().length()) appendLog(LOG_SECURITY_AUDIT, "authentication required for " + method + " " + path);
|
||||
sendJson(401, jsonError("Authentication required"));
|
||||
return false;
|
||||
}
|
||||
User user;
|
||||
if (!findUser(token->user, user) || !user.active) {
|
||||
appendLog(LOG_SECURITY_AUDIT, "inactive or unknown user token for " + token->user + " on " + method + " " + path);
|
||||
sendJson(401, jsonError("User is inactive"));
|
||||
return false;
|
||||
}
|
||||
token->roles = user.roles;
|
||||
if (!hasRole(user.roles, required)) {
|
||||
appendLog(LOG_SECURITY_AUDIT, "authorization denied for " + user.name + " on " + method + " " + path + " requires " + required);
|
||||
sendJson(403, jsonError("Missing role " + required));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
68
src/core/device.cpp
Normal file
68
src/core/device.cpp
Normal file
@@ -0,0 +1,68 @@
|
||||
#include "app.h"
|
||||
|
||||
#include <ESPmDNS.h>
|
||||
#include <LittleFS.h>
|
||||
|
||||
void applyLed() {
|
||||
uint8_t pct = constrain(ledBrightness, 0, 100);
|
||||
uint8_t duty = map(pct, 0, 100, 0, 255);
|
||||
if (ledInverted) duty = 255 - duty;
|
||||
analogWrite(LED_BUILTIN, duty);
|
||||
}
|
||||
|
||||
void factoryReset() {
|
||||
prefs.clear();
|
||||
LittleFS.remove(LOG_FILE_PATH);
|
||||
appendLog(LOG_SECURITY_AUDIT, "factory reset requested");
|
||||
}
|
||||
|
||||
void checkFactoryResetPin() {
|
||||
pinMode(FACTORY_RESET_PIN, INPUT_PULLUP);
|
||||
if (digitalRead(FACTORY_RESET_PIN) != FACTORY_RESET_ACTIVE_LEVEL) return;
|
||||
uint32_t start = millis();
|
||||
while (millis() - start < FACTORY_RESET_HOLD_MS) {
|
||||
delay(100);
|
||||
if (digitalRead(FACTORY_RESET_PIN) != FACTORY_RESET_ACTIVE_LEVEL) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
factoryReset();
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
bool connectWifi() {
|
||||
String ssid = prefString("wifiSsid", "");
|
||||
String pass = prefString("wifiPass", "");
|
||||
if (!ssid.length()) return false;
|
||||
String name = deviceName();
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.setHostname(name.c_str());
|
||||
if (!networkDhcp) {
|
||||
IPAddress ip;
|
||||
IPAddress gateway;
|
||||
IPAddress subnet;
|
||||
IPAddress dns1;
|
||||
IPAddress dns2;
|
||||
bool ok = ip.fromString(networkIp) && gateway.fromString(networkGateway) && subnet.fromString(networkSubnet);
|
||||
if (ok) {
|
||||
if (!networkDns1.length() || !dns1.fromString(networkDns1)) dns1 = gateway;
|
||||
if (!networkDns2.length() || !dns2.fromString(networkDns2)) dns2 = IPAddress(0, 0, 0, 0);
|
||||
if (!WiFi.config(ip, gateway, subnet, dns1, dns2)) appendLog(LOG_WARN, "static IP configuration failed; continuing with DHCP");
|
||||
} else {
|
||||
appendLog(LOG_WARN, "static IP configuration is incomplete or invalid; continuing with DHCP");
|
||||
}
|
||||
}
|
||||
WiFi.begin(ssid.c_str(), pass.c_str());
|
||||
appendLog(LOG_INFO, "connecting to WiFi " + ssid);
|
||||
uint32_t start = millis();
|
||||
while (WiFi.status() != WL_CONNECTED && millis() - start < 20000) {
|
||||
delay(250);
|
||||
}
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
MDNS.begin(name.c_str());
|
||||
appendLog(LOG_INFO, "WiFi connected " + WiFi.localIP().toString());
|
||||
return true;
|
||||
}
|
||||
appendLog(LOG_WARN, "WiFi connection failed; entering setup AP");
|
||||
return false;
|
||||
}
|
||||
43
src/core/logging.cpp
Normal file
43
src/core/logging.cpp
Normal file
@@ -0,0 +1,43 @@
|
||||
#include "app.h"
|
||||
|
||||
#include <LittleFS.h>
|
||||
#include <time.h>
|
||||
|
||||
static String timestamp() {
|
||||
time_t now = time(nullptr);
|
||||
struct tm tm;
|
||||
localtime_r(&now, &tm);
|
||||
char buffer[20];
|
||||
strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &tm);
|
||||
return String(buffer);
|
||||
}
|
||||
|
||||
void appendLog(LogLevel level, const String &message) {
|
||||
if (level > currentLogLevel) return;
|
||||
const char *names[] = {"ERROR", "WARN", "SecurityAudit", "INFO", "DEBUG"};
|
||||
String line = timestamp() + " " + names[level] + " " + message + "\n";
|
||||
if (!LittleFS.exists(LOG_DIR)) LittleFS.mkdir(LOG_DIR);
|
||||
File f = LittleFS.open(LOG_FILE_PATH, "a");
|
||||
if (f) {
|
||||
f.print(line);
|
||||
f.close();
|
||||
}
|
||||
f = LittleFS.open(LOG_FILE_PATH, "r");
|
||||
if (!f) return;
|
||||
size_t size = f.size();
|
||||
if (size <= maxLogBytes) {
|
||||
f.close();
|
||||
return;
|
||||
}
|
||||
size_t keep = maxLogBytes > 1024 ? maxLogBytes - 1024 : maxLogBytes;
|
||||
f.seek(size - keep, SeekSet);
|
||||
String tail = f.readString();
|
||||
f.close();
|
||||
int newline = tail.indexOf('\n');
|
||||
if (newline >= 0) tail = tail.substring(newline + 1);
|
||||
f = LittleFS.open(LOG_FILE_PATH, "w");
|
||||
if (f) {
|
||||
f.print(tail);
|
||||
f.close();
|
||||
}
|
||||
}
|
||||
117
src/core/state.cpp
Normal file
117
src/core/state.cpp
Normal file
@@ -0,0 +1,117 @@
|
||||
#include "app.h"
|
||||
|
||||
#include <mbedtls/md.h>
|
||||
|
||||
const char *APP_VERSION = "0.1.0";
|
||||
const char *PROJECT_NAME_VALUE = PROJECT_NAME;
|
||||
const char *DEFAULT_ADMIN = "admin";
|
||||
const char *DEFAULT_UPDATE_URL = "http://example.com/update.tslpkg";
|
||||
const uint32_t FACTORY_RESET_HOLD_MS = 10000;
|
||||
const size_t MAX_USERS = 8;
|
||||
const size_t MAX_TOKENS = 8;
|
||||
const bool DEFAULT_LED_INVERTED = true;
|
||||
const byte DNS_PORT = 53;
|
||||
const IPAddress SETUP_AP_IP(192, 168, 1, 1);
|
||||
const IPAddress SETUP_AP_GATEWAY(192, 168, 1, 1);
|
||||
const IPAddress SETUP_AP_SUBNET(255, 255, 255, 0);
|
||||
const char *WWW_ROOT = "/www";
|
||||
const char *LOG_DIR = "/log";
|
||||
const char *LOG_FILE_PATH = "/log/logs.txt";
|
||||
|
||||
WebServer server(80);
|
||||
DNSServer dnsServer;
|
||||
Preferences prefs;
|
||||
Token tokens[8];
|
||||
LogLevel currentLogLevel = LOG_INFO;
|
||||
size_t maxLogBytes = 50 * 1024;
|
||||
bool setupMode = false;
|
||||
uint8_t ledBrightness = 0;
|
||||
bool ledInverted = DEFAULT_LED_INVERTED;
|
||||
String updateUrl = DEFAULT_UPDATE_URL;
|
||||
String networkHostname;
|
||||
bool networkDhcp = true;
|
||||
String networkIp;
|
||||
String networkGateway;
|
||||
String networkSubnet;
|
||||
String networkDns1;
|
||||
String networkDns2;
|
||||
|
||||
String chipId() {
|
||||
uint64_t mac = ESP.getEfuseMac();
|
||||
char buf[13];
|
||||
snprintf(buf, sizeof(buf), "%04X%08X", (uint16_t)(mac >> 32), (uint32_t)mac);
|
||||
return String(buf);
|
||||
}
|
||||
|
||||
String defaultDeviceName() {
|
||||
String suffix = chipId().substring(6);
|
||||
String base = PROJECT_NAME_VALUE;
|
||||
base.trim();
|
||||
for (size_t i = 0; i < base.length(); i++) {
|
||||
char c = base[i];
|
||||
if (!(isalnum(c) || c == '-')) base.setCharAt(i, '-');
|
||||
}
|
||||
while (base.startsWith("-")) base.remove(0, 1);
|
||||
while (base.endsWith("-")) base.remove(base.length() - 1);
|
||||
if (!base.length()) base = "ESP32C3";
|
||||
|
||||
const size_t maxHostnameLength = 31;
|
||||
size_t maxBaseLength = maxHostnameLength - 1 - suffix.length();
|
||||
if (base.length() > maxBaseLength) base = base.substring(0, maxBaseLength);
|
||||
while (base.endsWith("-")) base.remove(base.length() - 1);
|
||||
return base + "-" + suffix;
|
||||
}
|
||||
|
||||
String deviceName() {
|
||||
return networkHostname.length() ? networkHostname : defaultDeviceName();
|
||||
}
|
||||
|
||||
String sha256(const String &input) {
|
||||
uint8_t digest[32];
|
||||
mbedtls_md_context_t ctx;
|
||||
mbedtls_md_init(&ctx);
|
||||
mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), 0);
|
||||
mbedtls_md_starts(&ctx);
|
||||
mbedtls_md_update(&ctx, (const unsigned char *)input.c_str(), input.length());
|
||||
mbedtls_md_finish(&ctx, digest);
|
||||
mbedtls_md_free(&ctx);
|
||||
char out[65];
|
||||
for (int i = 0; i < 32; i++) snprintf(out + (i * 2), 3, "%02x", digest[i]);
|
||||
out[64] = 0;
|
||||
return String(out);
|
||||
}
|
||||
|
||||
String passwordHash(const String &password) {
|
||||
return sha256(chipId() + ":" + password);
|
||||
}
|
||||
|
||||
String prefString(const char *key, const String &fallback) {
|
||||
return prefs.isKey(key) ? prefs.getString(key, fallback) : fallback;
|
||||
}
|
||||
|
||||
void loadSettings() {
|
||||
prefs.begin("boiler", false);
|
||||
if (!prefs.getBool("ledPolV2", false)) {
|
||||
prefs.putBool("ledInv", DEFAULT_LED_INVERTED);
|
||||
prefs.putBool("ledPolV2", true);
|
||||
}
|
||||
uint8_t rawLogLevel = prefs.getUChar("logLevel", LOG_INFO);
|
||||
if (!prefs.getBool("logLevelV2", false)) {
|
||||
if (prefs.isKey("logLevel") && rawLogLevel >= 2) rawLogLevel++;
|
||||
rawLogLevel = constrain(rawLogLevel, 0, 4);
|
||||
prefs.putUChar("logLevel", rawLogLevel);
|
||||
prefs.putBool("logLevelV2", true);
|
||||
}
|
||||
currentLogLevel = (LogLevel)constrain(rawLogLevel, 0, 4);
|
||||
maxLogBytes = prefs.getUInt("logMax", 50 * 1024);
|
||||
ledInverted = prefs.getBool("ledInv", DEFAULT_LED_INVERTED);
|
||||
ledBrightness = constrain(prefs.getUChar("ledBright", 0), 0, 100);
|
||||
updateUrl = prefString("updateUrl", DEFAULT_UPDATE_URL);
|
||||
networkHostname = prefString("netHost", "");
|
||||
networkDhcp = prefs.getBool("netDhcp", true);
|
||||
networkIp = prefString("netIp", "");
|
||||
networkGateway = prefString("netGw", "");
|
||||
networkSubnet = prefString("netMask", "");
|
||||
networkDns1 = prefString("netDns1", "");
|
||||
networkDns2 = prefString("netDns2", "");
|
||||
}
|
||||
435
src/handlers/handlers_admin.cpp
Normal file
435
src/handlers/handlers_admin.cpp
Normal file
@@ -0,0 +1,435 @@
|
||||
#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()) return;
|
||||
String out = "\"settings\":{\"projectName\":\"" + jsonEscape(PROJECT_NAME_VALUE) + "\",\"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(LOG_FILE_PATH, "r");
|
||||
String logs = f ? f.readString() : "";
|
||||
if (f) f.close();
|
||||
sendJson(200, jsonOk("\"logs\":\"" + jsonEscape(logs) + "\""));
|
||||
}
|
||||
|
||||
void handleLogsClear() {
|
||||
if (!authorize()) return;
|
||||
LittleFS.remove(LOG_FILE_PATH);
|
||||
appendLog(LOG_SECURITY_AUDIT, "logs cleared");
|
||||
sendJson(200, jsonOk());
|
||||
}
|
||||
|
||||
static bool validFsPath(const String &path) {
|
||||
return path.length() && path.startsWith("/") && path.indexOf("..") < 0 && path.indexOf('\\') < 0;
|
||||
}
|
||||
|
||||
static String fsPathArg(const String &fallback = "/") {
|
||||
String path = server.arg("path");
|
||||
if (!path.length()) path = fallback;
|
||||
if (!path.startsWith("/")) path = "/" + path;
|
||||
while (path.length() > 1 && path.endsWith("/")) path.remove(path.length() - 1);
|
||||
return path;
|
||||
}
|
||||
|
||||
static String joinedFsPath(const String &parent, const String &name) {
|
||||
if (name.startsWith("/")) return name;
|
||||
if (parent == "/") return "/" + name;
|
||||
return parent + "/" + name;
|
||||
}
|
||||
|
||||
static void appendFsEntryJson(String &out, bool &first, const String &parent, File &file) {
|
||||
String path = file.name();
|
||||
path = joinedFsPath(parent, path);
|
||||
if (!first) out += ",";
|
||||
out += "{\"name\":\"" + jsonEscape(path.substring(path.lastIndexOf('/') + 1)) + "\",\"path\":\"" + jsonEscape(path) + "\",\"size\":" + String(file.isDirectory() ? 0 : file.size()) + ",\"directory\":" + String(file.isDirectory() ? "true" : "false") + "}";
|
||||
first = false;
|
||||
}
|
||||
|
||||
static void appendDirectoryJson(String &out, bool &first, const String &path, File &dir) {
|
||||
File file = dir.openNextFile();
|
||||
while (file) {
|
||||
appendFsEntryJson(out, first, path, file);
|
||||
file.close();
|
||||
file = dir.openNextFile();
|
||||
}
|
||||
}
|
||||
|
||||
void handleFilesList() {
|
||||
if (!authorize()) return;
|
||||
String path = fsPathArg("/");
|
||||
if (!validFsPath(path)) return sendJson(400, jsonError("Invalid path"));
|
||||
File dir = LittleFS.open(path, "r");
|
||||
if (!dir && path != "/") dir = LittleFS.open(path + "/", "r");
|
||||
if (!dir) return sendJson(404, jsonError("Directory not found"));
|
||||
if (!dir.isDirectory()) {
|
||||
dir.close();
|
||||
return sendJson(400, jsonError("Path is not a directory"));
|
||||
}
|
||||
|
||||
String out = "\"path\":\"" + jsonEscape(path) + "\",\"files\":[";
|
||||
bool first = true;
|
||||
appendDirectoryJson(out, first, path, dir);
|
||||
dir.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\""));
|
||||
}
|
||||
35
src/handlers/handlers_api.cpp
Normal file
35
src/handlers/handlers_api.cpp
Normal file
@@ -0,0 +1,35 @@
|
||||
#include "app.h"
|
||||
|
||||
void handleApisGet() {
|
||||
if (!authorize()) return;
|
||||
String out = "\"apis\":[";
|
||||
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 += ",";
|
||||
String role = configuredRole(api);
|
||||
out += "{\"path\":\"" + String(api->path) + "\",\"method\":\"" + String(api->method) + "\",\"role\":\"" + jsonEscape(role) + "\"}";
|
||||
}
|
||||
out += "]";
|
||||
sendJson(200, jsonOk(out));
|
||||
}
|
||||
|
||||
void handleApisPost() {
|
||||
if (!authorize()) return;
|
||||
String body = requestBody();
|
||||
String path = jsonStringValue(body, "path");
|
||||
String method = jsonStringValue(body, "method", "GET");
|
||||
method.toUpperCase();
|
||||
String role = jsonStringValue(body, "role", "PUBLIC");
|
||||
ApiDef *api = findApi(path, method);
|
||||
if (!api) return sendJson(404, jsonError("Unknown API"));
|
||||
if (!isKnownRole(role)) return sendJson(400, jsonError("Set one known role or PUBLIC"));
|
||||
String key = "acl";
|
||||
key += method[0];
|
||||
for (size_t i = 0; i < path.length(); i++) {
|
||||
char c = path[i];
|
||||
if (isalnum(c)) key += c;
|
||||
}
|
||||
prefs.putString(key.substring(0, 15).c_str(), role);
|
||||
appendLog(LOG_SECURITY_AUDIT, "API ACL saved " + method + " " + path + " -> " + role);
|
||||
sendJson(200, jsonOk());
|
||||
}
|
||||
107
src/handlers/handlers_auth.cpp
Normal file
107
src/handlers/handlers_auth.cpp
Normal file
@@ -0,0 +1,107 @@
|
||||
#include "app.h"
|
||||
|
||||
void handleLogin() {
|
||||
String body = requestBody();
|
||||
String username = jsonStringValue(body, "username");
|
||||
String password = jsonStringValue(body, "password");
|
||||
User user;
|
||||
if (!findUser(username, user) || !user.active || user.passwordHash != passwordHash(password)) {
|
||||
appendLog(LOG_SECURITY_AUDIT, "failed login for " + username);
|
||||
return sendJson(401, jsonError("Invalid username or password"));
|
||||
}
|
||||
String token = createToken(user);
|
||||
appendLog(LOG_SECURITY_AUDIT, "login " + username);
|
||||
sendJson(200, jsonOk("\"token\":\"" + token + "\",\"username\":\"" + jsonEscape(user.name) + "\",\"roles\":\"" + jsonEscape(user.roles) + "\""));
|
||||
}
|
||||
|
||||
void handleLogout() {
|
||||
if (!authorize()) return;
|
||||
Token *tok = currentToken();
|
||||
if (tok) {
|
||||
appendLog(LOG_SECURITY_AUDIT, "logout " + tok->user);
|
||||
*tok = Token();
|
||||
}
|
||||
sendJson(200, jsonOk());
|
||||
}
|
||||
|
||||
void handleMe() {
|
||||
if (!authorize()) return;
|
||||
Token *tok = currentToken();
|
||||
sendJson(200, jsonOk("\"username\":\"" + jsonEscape(tok->user) + "\",\"roles\":\"" + jsonEscape(tok->roles) + "\""));
|
||||
}
|
||||
|
||||
void handleUsersGet() {
|
||||
if (!authorize()) return;
|
||||
User users[8];
|
||||
size_t count = parseUsers(users, MAX_USERS);
|
||||
String out = "\"users\":[";
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
if (i) out += ",";
|
||||
out += "{\"username\":\"" + jsonEscape(users[i].name) + "\",\"roles\":\"" + jsonEscape(users[i].roles) + "\",\"active\":" + String(users[i].active ? "true" : "false") + "}";
|
||||
}
|
||||
out += "]";
|
||||
sendJson(200, jsonOk(out));
|
||||
}
|
||||
|
||||
void handleUsersPost() {
|
||||
if (!authorize()) return;
|
||||
String body = requestBody();
|
||||
String username = jsonStringValue(body, "username");
|
||||
String password = jsonStringValue(body, "password");
|
||||
String roles = cleanRoles(jsonRolesValue(body, "roles", "WebUIConnect"));
|
||||
bool active = jsonBoolValue(body, "active", true);
|
||||
if (!validName(username)) return sendJson(400, jsonError("Username is invalid"));
|
||||
User users[8];
|
||||
size_t count = parseUsers(users, MAX_USERS);
|
||||
size_t idx = count;
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
if (users[i].name == username) idx = i;
|
||||
}
|
||||
if (idx == count && count >= MAX_USERS) return sendJson(400, jsonError("Maximum user count reached"));
|
||||
users[idx].name = username;
|
||||
if (password.length() || idx == count) users[idx].passwordHash = passwordHash(password);
|
||||
users[idx].roles = roles;
|
||||
users[idx].active = active;
|
||||
if (idx == count) count++;
|
||||
saveUsers(users, count);
|
||||
appendLog(LOG_SECURITY_AUDIT, "user saved " + username);
|
||||
sendJson(200, jsonOk());
|
||||
}
|
||||
|
||||
void handlePassword() {
|
||||
if (!authorize()) return;
|
||||
Token *tok = currentToken();
|
||||
String password = jsonStringValue(requestBody(), "password");
|
||||
User users[8];
|
||||
size_t count = parseUsers(users, MAX_USERS);
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
if (users[i].name == tok->user) {
|
||||
users[i].passwordHash = passwordHash(password);
|
||||
saveUsers(users, count);
|
||||
appendLog(LOG_SECURITY_AUDIT, "password changed " + tok->user);
|
||||
return sendJson(200, jsonOk());
|
||||
}
|
||||
}
|
||||
sendJson(404, jsonError("User not found"));
|
||||
}
|
||||
|
||||
void handleRolesGet() {
|
||||
if (!authorize()) return;
|
||||
sendJson(200, jsonOk(rolesJson()));
|
||||
}
|
||||
|
||||
void handleRolesPost() {
|
||||
if (!authorize()) return;
|
||||
String role = jsonStringValue(requestBody(), "role");
|
||||
if (!addCustomRole(role)) return sendJson(400, jsonError("Role is invalid or already exists"));
|
||||
appendLog(LOG_SECURITY_AUDIT, "role added " + role);
|
||||
sendJson(200, jsonOk(rolesJson()));
|
||||
}
|
||||
|
||||
void handleRolesDelete() {
|
||||
if (!authorize()) return;
|
||||
String role = jsonStringValue(requestBody(), "role", server.arg("role"));
|
||||
if (!deleteCustomRole(role)) return sendJson(400, jsonError("Role cannot be deleted"));
|
||||
appendLog(LOG_SECURITY_AUDIT, "role deleted " + role);
|
||||
sendJson(200, jsonOk(rolesJson()));
|
||||
}
|
||||
87
src/handlers/handlers_custom_api.cpp
Normal file
87
src/handlers/handlers_custom_api.cpp
Normal file
@@ -0,0 +1,87 @@
|
||||
#include "app.h"
|
||||
#include "custom_api.h"
|
||||
|
||||
#include <DallasTemperature.h>
|
||||
#include <OneWire.h>
|
||||
|
||||
static OneWire oneWire(DS18B20_PIN);
|
||||
static DallasTemperature temperatureSensors(&oneWire);
|
||||
static bool temperatureStarted = false;
|
||||
static bool temperaturePending = false;
|
||||
static float lastTemperatureC = NAN;
|
||||
static uint32_t temperatureRequestMs = 0;
|
||||
|
||||
static void beginTemperatureSensor() {
|
||||
if (temperatureStarted) return;
|
||||
temperatureSensors.begin();
|
||||
temperatureSensors.setResolution(12);
|
||||
temperatureSensors.setWaitForConversion(false);
|
||||
temperatureSensors.requestTemperatures();
|
||||
temperatureRequestMs = millis();
|
||||
temperaturePending = true;
|
||||
temperatureStarted = true;
|
||||
}
|
||||
|
||||
static void updateTemperatureSensor() {
|
||||
beginTemperatureSensor();
|
||||
uint32_t now = millis();
|
||||
if (temperaturePending && now - temperatureRequestMs >= 750) {
|
||||
float value = temperatureSensors.getTempCByIndex(0);
|
||||
if (value != DEVICE_DISCONNECTED_C) lastTemperatureC = value;
|
||||
temperaturePending = false;
|
||||
}
|
||||
if (!temperaturePending && now - temperatureRequestMs >= 1000) {
|
||||
temperatureSensors.requestTemperatures();
|
||||
temperatureRequestMs = now;
|
||||
temperaturePending = true;
|
||||
}
|
||||
}
|
||||
|
||||
static String temperatureJsonFields() {
|
||||
updateTemperatureSensor();
|
||||
String json = "\"pin\":" + String(DS18B20_PIN);
|
||||
json += ",\"sensorConnected\":";
|
||||
json += isnan(lastTemperatureC) ? "false" : "true";
|
||||
json += ",\"temperatureC\":";
|
||||
json += isnan(lastTemperatureC) ? "null" : String(lastTemperatureC, 2);
|
||||
json += ",\"temperatureF\":";
|
||||
json += isnan(lastTemperatureC) ? "null" : String((lastTemperatureC * 9.0f / 5.0f) + 32.0f, 2);
|
||||
return json;
|
||||
}
|
||||
|
||||
void handlePing() {
|
||||
if (!authorize()) return;
|
||||
sendJson(200, jsonOk("\"uptimeMs\":" + String(millis()) + ",\"version\":\"" + APP_VERSION + "\",\"name\":\"" + jsonEscape(deviceName()) + "\",\"ip\":\"" + WiFi.localIP().toString() + "\""));
|
||||
}
|
||||
|
||||
void handleAdd() {
|
||||
if (!authorize()) return;
|
||||
String body = requestBody();
|
||||
int a = jsonIntValue(body, "a", server.arg("a").toInt());
|
||||
int b = jsonIntValue(body, "b", server.arg("b").toInt());
|
||||
sendJson(200, jsonOk("\"result\":" + String(a + b)));
|
||||
}
|
||||
|
||||
void handleLed() {
|
||||
if (!authorize()) return;
|
||||
ledBrightness = constrain(jsonIntValue(requestBody(), "brightness", server.arg("brightness").toInt()), 0, 100);
|
||||
prefs.putUChar("ledBright", ledBrightness);
|
||||
applyLed();
|
||||
appendLog(LOG_INFO, "LED brightness set to " + String(ledBrightness));
|
||||
sendJson(200, jsonOk("\"brightness\":" + String(ledBrightness)));
|
||||
}
|
||||
|
||||
void handleUptime() {
|
||||
if (!authorize()) return;
|
||||
sendJson(200, jsonOk(temperatureJsonFields()));
|
||||
}
|
||||
|
||||
void handleUptimeEvents() {
|
||||
if (!authorize()) return;
|
||||
String payload = "retry: 1000\n";
|
||||
payload += "event: temperature\n";
|
||||
payload += "data: {" + temperatureJsonFields() + "}\n\n";
|
||||
server.sendHeader("Cache-Control", "no-store");
|
||||
server.sendHeader("Connection", "close");
|
||||
server.send(200, "text/event-stream", payload);
|
||||
}
|
||||
202
src/handlers/handlers_ota.cpp
Normal file
202
src/handlers/handlers_ota.cpp
Normal file
@@ -0,0 +1,202 @@
|
||||
#include "app.h"
|
||||
|
||||
#include <HTTPClient.h>
|
||||
#include <LittleFS.h>
|
||||
#include <Update.h>
|
||||
|
||||
static const uint8_t PACKAGE_HEADER_SIZE = 32;
|
||||
static const char PACKAGE_MAGIC[8] = {'T', 'S', 'L', 'U', 'P', 'D', '1', 0};
|
||||
|
||||
enum PackageStage : uint8_t {
|
||||
PKG_HEADER,
|
||||
PKG_FILESYSTEM,
|
||||
PKG_FIRMWARE,
|
||||
PKG_DONE,
|
||||
PKG_ERROR
|
||||
};
|
||||
|
||||
struct PackageState {
|
||||
PackageStage stage;
|
||||
uint8_t header[PACKAGE_HEADER_SIZE];
|
||||
size_t headerRead;
|
||||
uint32_t filesystemSize;
|
||||
uint32_t firmwareSize;
|
||||
uint32_t remaining;
|
||||
bool filesystemEnded;
|
||||
String error;
|
||||
};
|
||||
|
||||
static PackageState packageState;
|
||||
|
||||
static uint32_t readLe32(const uint8_t *p) {
|
||||
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
|
||||
}
|
||||
|
||||
static void resetPackageState() {
|
||||
packageState.stage = PKG_HEADER;
|
||||
packageState.headerRead = 0;
|
||||
packageState.filesystemSize = 0;
|
||||
packageState.firmwareSize = 0;
|
||||
packageState.remaining = 0;
|
||||
packageState.filesystemEnded = false;
|
||||
packageState.error = "";
|
||||
}
|
||||
|
||||
static bool failPackage(const String &message) {
|
||||
packageState.stage = PKG_ERROR;
|
||||
packageState.error = message;
|
||||
Update.abort();
|
||||
if (packageState.filesystemEnded) LittleFS.begin(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool beginPackagePart(uint32_t size, int command, const char *label) {
|
||||
if (command == U_SPIFFS) LittleFS.end();
|
||||
if (!Update.begin(size, command)) {
|
||||
return failPackage(String(label) + " update begin failed: " + Update.errorString());
|
||||
}
|
||||
packageState.remaining = size;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool finishPackagePart(const char *label) {
|
||||
if (!Update.end(true)) {
|
||||
return failPackage(String(label) + " update failed: " + Update.errorString());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool parsePackageHeader() {
|
||||
if (memcmp(packageState.header, PACKAGE_MAGIC, sizeof(PACKAGE_MAGIC)) != 0) {
|
||||
return failPackage("Invalid update package magic");
|
||||
}
|
||||
uint32_t headerSize = readLe32(packageState.header + 8);
|
||||
packageState.filesystemSize = readLe32(packageState.header + 12);
|
||||
packageState.firmwareSize = readLe32(packageState.header + 16);
|
||||
if (headerSize != PACKAGE_HEADER_SIZE) return failPackage("Unsupported update package header");
|
||||
if (!packageState.filesystemSize || !packageState.firmwareSize) return failPackage("Update package must contain filesystem and firmware images");
|
||||
packageState.stage = PKG_FILESYSTEM;
|
||||
return beginPackagePart(packageState.filesystemSize, U_SPIFFS, "Filesystem");
|
||||
}
|
||||
|
||||
static bool feedPackageBytes(const uint8_t *data, size_t length) {
|
||||
while (length && packageState.stage != PKG_ERROR && packageState.stage != PKG_DONE) {
|
||||
if (packageState.stage == PKG_HEADER) {
|
||||
size_t n = min(length, (size_t)PACKAGE_HEADER_SIZE - packageState.headerRead);
|
||||
memcpy(packageState.header + packageState.headerRead, data, n);
|
||||
packageState.headerRead += n;
|
||||
data += n;
|
||||
length -= n;
|
||||
if (packageState.headerRead == PACKAGE_HEADER_SIZE && !parsePackageHeader()) return false;
|
||||
} else {
|
||||
size_t n = min(length, (size_t)packageState.remaining);
|
||||
if (Update.write((uint8_t *)data, n) != n) {
|
||||
return failPackage(String(packageState.stage == PKG_FILESYSTEM ? "Filesystem" : "Firmware") + " write failed");
|
||||
}
|
||||
packageState.remaining -= n;
|
||||
data += n;
|
||||
length -= n;
|
||||
|
||||
if (packageState.remaining == 0) {
|
||||
if (packageState.stage == PKG_FILESYSTEM) {
|
||||
if (!finishPackagePart("Filesystem")) return false;
|
||||
packageState.filesystemEnded = true;
|
||||
packageState.stage = PKG_FIRMWARE;
|
||||
if (!beginPackagePart(packageState.firmwareSize, U_FLASH, "Firmware")) return false;
|
||||
} else {
|
||||
if (!finishPackagePart("Firmware")) return false;
|
||||
packageState.stage = PKG_DONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (length && packageState.stage == PKG_DONE) return failPackage("Trailing bytes in update package");
|
||||
return packageState.stage != PKG_ERROR;
|
||||
}
|
||||
|
||||
static bool streamPackageFromUrl(const String &url, String &error) {
|
||||
resetPackageState();
|
||||
HTTPClient http;
|
||||
http.begin(url);
|
||||
int code = http.GET();
|
||||
if (code != HTTP_CODE_OK) {
|
||||
http.end();
|
||||
error = "Update package URL returned HTTP " + String(code);
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t buffer[1024];
|
||||
WiFiClient *stream = http.getStreamPtr();
|
||||
int expectedLength = http.getSize();
|
||||
int receivedLength = 0;
|
||||
uint32_t lastRead = millis();
|
||||
while (expectedLength < 0 || receivedLength < expectedLength) {
|
||||
int available = stream->available();
|
||||
if (available > 0) {
|
||||
int wanted = min(available, (int)sizeof(buffer));
|
||||
if (expectedLength >= 0) wanted = min(wanted, expectedLength - receivedLength);
|
||||
int n = stream->readBytes(buffer, wanted);
|
||||
if (n > 0) {
|
||||
receivedLength += n;
|
||||
lastRead = millis();
|
||||
if (!feedPackageBytes(buffer, n)) break;
|
||||
}
|
||||
} else {
|
||||
if (!http.connected()) break;
|
||||
if (millis() - lastRead > 30000) {
|
||||
failPackage("Update package download timed out");
|
||||
break;
|
||||
}
|
||||
delay(10);
|
||||
}
|
||||
}
|
||||
http.end();
|
||||
|
||||
if (packageState.stage != PKG_DONE) {
|
||||
error = packageState.error.length() ? packageState.error : "Incomplete update package";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void handleOtaCheck() {
|
||||
if (!authorize()) return;
|
||||
HTTPClient http;
|
||||
http.begin(updateUrl);
|
||||
int code = http.GET();
|
||||
int size = http.getSize();
|
||||
http.end();
|
||||
sendJson(code > 0 && code < 400 ? 200 : 502, jsonOk("\"version\":\"" + String(APP_VERSION) + "\",\"url\":\"" + jsonEscape(updateUrl) + "\",\"httpStatus\":" + String(code) + ",\"contentLength\":" + String(size)));
|
||||
}
|
||||
|
||||
void handleOtaRun() {
|
||||
if (!authorize()) return;
|
||||
appendLog(LOG_SECURITY_AUDIT, "package URL update started");
|
||||
String error;
|
||||
if (!streamPackageFromUrl(updateUrl, error)) return sendJson(502, jsonError(error));
|
||||
sendJson(200, jsonOk("\"restart\":true,\"filesystemUpdated\":true,\"firmwareUpdated\":true"));
|
||||
delay(500);
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
void handleUpdateUploadDone() {
|
||||
if (!authorize()) return;
|
||||
bool ok = packageState.stage == PKG_DONE;
|
||||
String error = packageState.error.length() ? packageState.error : "Incomplete update package";
|
||||
sendJson(ok ? 200 : 500, ok ? jsonOk("\"restart\":true,\"filesystemUpdated\":true,\"firmwareUpdated\":true") : jsonError(error));
|
||||
if (ok) {
|
||||
delay(500);
|
||||
ESP.restart();
|
||||
}
|
||||
}
|
||||
|
||||
void handleUpdateUploadChunk() {
|
||||
HTTPUpload &upload = server.upload();
|
||||
if (upload.status == UPLOAD_FILE_START) {
|
||||
if (!authorize()) return;
|
||||
appendLog(LOG_SECURITY_AUDIT, "package upload started");
|
||||
resetPackageState();
|
||||
} else if (upload.status == UPLOAD_FILE_WRITE) {
|
||||
feedPackageBytes(upload.buf, upload.currentSize);
|
||||
}
|
||||
}
|
||||
33
src/handlers/handlers_setup.cpp
Normal file
33
src/handlers/handlers_setup.cpp
Normal file
@@ -0,0 +1,33 @@
|
||||
#include "app.h"
|
||||
|
||||
void handleWifiScan() {
|
||||
if (!setupMode && !authorize()) return;
|
||||
int n = WiFi.scanNetworks();
|
||||
String out = "\"networks\":[";
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (i) out += ",";
|
||||
out += "{\"ssid\":\"" + jsonEscape(WiFi.SSID(i)) + "\",\"rssi\":" + String(WiFi.RSSI(i)) + ",\"open\":" + String(WiFi.encryptionType(i) == WIFI_AUTH_OPEN ? "true" : "false") + "}";
|
||||
}
|
||||
out += "]";
|
||||
sendJson(200, jsonOk(out));
|
||||
}
|
||||
|
||||
void handleSetupSubmit() {
|
||||
if (!setupMode && !authorize()) return;
|
||||
String body = requestBody();
|
||||
String ssid = jsonStringValue(body, "ssid");
|
||||
String wifiPass = jsonStringValue(body, "wifiPass");
|
||||
String admin = jsonStringValue(body, "admin", DEFAULT_ADMIN);
|
||||
String adminPass = jsonStringValue(body, "adminPass");
|
||||
if (!ssid.length()) return sendJson(400, jsonError("WiFi SSID is required"));
|
||||
if (!validName(admin)) return sendJson(400, jsonError("Admin username is invalid"));
|
||||
prefs.putString("wifiSsid", ssid);
|
||||
prefs.putString("wifiPass", wifiPass);
|
||||
User u{admin, passwordHash(adminPass), "Sysadmin|UserAdmin|WebUIConnect|Debugger", true};
|
||||
saveUsers(&u, 1);
|
||||
prefs.putBool("configured", true);
|
||||
appendLog(LOG_SECURITY_AUDIT, "initial setup saved");
|
||||
sendJson(200, jsonOk("\"restart\":true"));
|
||||
delay(500);
|
||||
ESP.restart();
|
||||
}
|
||||
38
src/main.cpp
Normal file
38
src/main.cpp
Normal file
@@ -0,0 +1,38 @@
|
||||
#include "app.h"
|
||||
|
||||
#include <LittleFS.h>
|
||||
|
||||
void setup() {
|
||||
Serial.begin(9600);
|
||||
delay(200);
|
||||
Serial.println("Booting");
|
||||
LittleFS.begin(true);
|
||||
loadSettings();
|
||||
pinMode(LED_BUILTIN, OUTPUT);
|
||||
applyLed();
|
||||
checkFactoryResetPin();
|
||||
|
||||
bool configured = prefs.getBool("configured", false);
|
||||
setupMode = !configured || !connectWifi();
|
||||
if (setupMode) {
|
||||
String ssid = deviceName();
|
||||
WiFi.mode(WIFI_AP_STA);
|
||||
WiFi.softAPConfig(SETUP_AP_IP, SETUP_AP_GATEWAY, SETUP_AP_SUBNET);
|
||||
WiFi.softAP(ssid.c_str());
|
||||
dnsServer.start(DNS_PORT, "*", SETUP_AP_IP);
|
||||
appendLog(LOG_INFO, "setup AP started " + ssid);
|
||||
Serial.println("Setup AP: " + ssid + " http://" + SETUP_AP_IP.toString() + "/");
|
||||
} else {
|
||||
Serial.println("Admin UI: http://" + WiFi.localIP().toString() + "/");
|
||||
Serial.println("Admin UI local: http://" + deviceName() + ".local/");
|
||||
}
|
||||
|
||||
registerRoutes();
|
||||
server.begin();
|
||||
appendLog(LOG_INFO, "HTTP server started");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (setupMode) dnsServer.processNextRequest();
|
||||
server.handleClient();
|
||||
}
|
||||
106
src/util/json_utils.cpp
Normal file
106
src/util/json_utils.cpp
Normal file
@@ -0,0 +1,106 @@
|
||||
#include "app.h"
|
||||
|
||||
static int findJsonKey(const String &json, const char *key) {
|
||||
String needle = "\"" + String(key) + "\"";
|
||||
int p = json.indexOf(needle);
|
||||
if (p < 0) return -1;
|
||||
p = json.indexOf(':', p + needle.length());
|
||||
return p < 0 ? -1 : p + 1;
|
||||
}
|
||||
|
||||
String jsonEscape(const String &s) {
|
||||
String out;
|
||||
out.reserve(s.length() + 8);
|
||||
for (size_t i = 0; i < s.length(); i++) {
|
||||
char c = s[i];
|
||||
if (c == '"' || c == '\\') {
|
||||
out += '\\';
|
||||
out += c;
|
||||
} else if (c == '\n') {
|
||||
out += "\\n";
|
||||
} else if (c == '\r') {
|
||||
out += "\\r";
|
||||
} else {
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
String jsonOk(const String &payload) {
|
||||
return String("{\"success\":true") + (payload.length() ? "," + payload : "") + "}";
|
||||
}
|
||||
|
||||
String jsonError(const String &message) {
|
||||
return "{\"success\":false,\"error\":\"" + jsonEscape(message) + "\"}";
|
||||
}
|
||||
|
||||
void sendJson(int code, const String &body) {
|
||||
server.sendHeader("Cache-Control", "no-store");
|
||||
server.send(code, "application/json", body);
|
||||
}
|
||||
|
||||
String requestBody() {
|
||||
return server.hasArg("plain") ? server.arg("plain") : "";
|
||||
}
|
||||
|
||||
String jsonStringValue(const String &json, const char *key, const String &fallback) {
|
||||
int p = findJsonKey(json, key);
|
||||
if (p < 0) return fallback;
|
||||
while (p < (int)json.length() && isspace(json[p])) p++;
|
||||
if (p >= (int)json.length() || json[p] != '"') return fallback;
|
||||
p++;
|
||||
String out;
|
||||
bool esc = false;
|
||||
for (; p < (int)json.length(); p++) {
|
||||
char c = json[p];
|
||||
if (esc) {
|
||||
out += c == 'n' ? '\n' : c == 'r' ? '\r' : c;
|
||||
esc = false;
|
||||
} else if (c == '\\') {
|
||||
esc = true;
|
||||
} else if (c == '"') {
|
||||
return out;
|
||||
} else {
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
int jsonIntValue(const String &json, const char *key, int fallback) {
|
||||
int p = findJsonKey(json, key);
|
||||
if (p < 0) return fallback;
|
||||
while (p < (int)json.length() && isspace(json[p])) p++;
|
||||
return json.substring(p).toInt();
|
||||
}
|
||||
|
||||
bool jsonBoolValue(const String &json, const char *key, bool fallback) {
|
||||
int p = findJsonKey(json, key);
|
||||
if (p < 0) return fallback;
|
||||
while (p < (int)json.length() && isspace(json[p])) p++;
|
||||
if (json.substring(p, p + 4) == "true") return true;
|
||||
if (json.substring(p, p + 5) == "false") return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
String jsonRolesValue(const String &json, const char *key, const String &fallback) {
|
||||
int p = findJsonKey(json, key);
|
||||
if (p < 0) return fallback;
|
||||
while (p < (int)json.length() && isspace(json[p])) p++;
|
||||
if (json[p] == '"') return jsonStringValue(json, key, fallback);
|
||||
if (json[p] != '[') return fallback;
|
||||
String roles;
|
||||
p++;
|
||||
while (p < (int)json.length() && json[p] != ']') {
|
||||
while (p < (int)json.length() && json[p] != '"' && json[p] != ']') p++;
|
||||
if (p >= (int)json.length() || json[p] == ']') break;
|
||||
p++;
|
||||
String role;
|
||||
while (p < (int)json.length() && json[p] != '"') role += json[p++];
|
||||
if (roles.length()) roles += "|";
|
||||
roles += role;
|
||||
p++;
|
||||
}
|
||||
return roles.length() ? roles : fallback;
|
||||
}
|
||||
76
src/web/routes.cpp
Normal file
76
src/web/routes.cpp
Normal file
@@ -0,0 +1,76 @@
|
||||
#include "app.h"
|
||||
|
||||
static HTTPMethod httpMethod(const char *method) {
|
||||
if (strcmp(method, "GET") == 0) return HTTP_GET;
|
||||
if (strcmp(method, "POST") == 0) return HTTP_POST;
|
||||
if (strcmp(method, "DELETE") == 0) return HTTP_DELETE;
|
||||
if (strcmp(method, "PUT") == 0) return HTTP_PUT;
|
||||
if (strcmp(method, "PATCH") == 0) return HTTP_PATCH;
|
||||
return HTTP_ANY;
|
||||
}
|
||||
|
||||
static void registerApiDefs(ApiDef *defs, size_t count) {
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
ApiDef &api = defs[i];
|
||||
if (api.uploadHandler) {
|
||||
server.on(api.path, httpMethod(api.method), api.handler, api.uploadHandler);
|
||||
} else {
|
||||
server.on(api.path, httpMethod(api.method), api.handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void registerApiRoutes() {
|
||||
registerApiDefs(apiDefs, API_DEF_COUNT);
|
||||
registerApiDefs(customApiDefs, CUSTOM_API_DEF_COUNT);
|
||||
}
|
||||
|
||||
static String requestMethodName() {
|
||||
switch (server.method()) {
|
||||
case HTTP_GET:
|
||||
return "GET";
|
||||
case HTTP_POST:
|
||||
return "POST";
|
||||
case HTTP_DELETE:
|
||||
return "DELETE";
|
||||
case HTTP_PUT:
|
||||
return "PUT";
|
||||
case HTTP_PATCH:
|
||||
return "PATCH";
|
||||
default:
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
void registerRoutes() {
|
||||
static const char *headers[] = {"Authorization", "X-Auth-Token"};
|
||||
server.collectHeaders(headers, 2);
|
||||
|
||||
server.on("/", HTTP_GET, setupMode ? handleSetupPage : handleAdminPage);
|
||||
server.on("/favicon.ico", HTTP_GET, handleFavicon);
|
||||
server.on("/generate_204", HTTP_GET, handleCaptiveProbe);
|
||||
server.on("/gen_204", HTTP_GET, handleCaptiveProbe);
|
||||
server.on("/hotspot-detect.html", HTTP_GET, handleCaptiveProbe);
|
||||
server.on("/library/test/success.html", HTTP_GET, handleCaptiveProbe);
|
||||
server.on("/connecttest.txt", HTTP_GET, handleCaptiveProbe);
|
||||
server.on("/ncsi.txt", HTTP_GET, handleCaptiveProbe);
|
||||
server.on("/fwlink", HTTP_GET, handleCaptiveProbe);
|
||||
|
||||
registerApiRoutes();
|
||||
|
||||
server.onNotFound([]() {
|
||||
if (setupMode) {
|
||||
if (server.uri().startsWith("/api/")) {
|
||||
appendLog(LOG_SECURITY_AUDIT, "undefined URL or method " + requestMethodName() + " " + server.uri());
|
||||
sendJson(404, jsonError("Not found"));
|
||||
} else if (!handleHtmlFileRequest()) {
|
||||
redirectToSetupPage();
|
||||
}
|
||||
} else if (server.method() == HTTP_GET && !server.uri().startsWith("/api/")) {
|
||||
if (!handleHtmlFileRequest()) handleAdminPage();
|
||||
} else {
|
||||
appendLog(LOG_SECURITY_AUDIT, "undefined URL or method " + requestMethodName() + " " + server.uri());
|
||||
sendJson(404, jsonError("Not found"));
|
||||
}
|
||||
});
|
||||
}
|
||||
69
src/web/ui.cpp
Normal file
69
src/web/ui.cpp
Normal file
@@ -0,0 +1,69 @@
|
||||
#include "app.h"
|
||||
|
||||
#include <LittleFS.h>
|
||||
|
||||
static bool validPublicPath(const String &path) {
|
||||
return path.startsWith("/") && path.indexOf("..") < 0 && path.indexOf('\\') < 0;
|
||||
}
|
||||
|
||||
static String contentTypeFor(const String &path) {
|
||||
if (path.endsWith(".html")) return "text/html";
|
||||
if (path.endsWith(".css")) return "text/css";
|
||||
if (path.endsWith(".js") || path.endsWith(".mjs")) return "text/javascript";
|
||||
if (path.endsWith(".json")) return "application/json";
|
||||
if (path.endsWith(".svg")) return "image/svg+xml";
|
||||
if (path.endsWith(".png")) return "image/png";
|
||||
if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (path.endsWith(".ico")) return "image/x-icon";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
static bool serveWwwFile(const String &path) {
|
||||
if (!validPublicPath(path)) return false;
|
||||
String fsPath = String(WWW_ROOT) + path;
|
||||
File file = LittleFS.open(fsPath, "r");
|
||||
if (!file || file.isDirectory()) {
|
||||
if (file) file.close();
|
||||
return false;
|
||||
}
|
||||
server.sendHeader("Cache-Control", path.endsWith(".html") ? "no-store" : "max-age=3600");
|
||||
server.streamFile(file, contentTypeFor(path));
|
||||
file.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
void handleSetupPage() {
|
||||
if (!serveWwwFile("/setup.html")) {
|
||||
sendJson(500, jsonError("Missing /www/setup.html in LittleFS"));
|
||||
}
|
||||
}
|
||||
|
||||
bool handleHtmlFileRequest() {
|
||||
String path = server.uri();
|
||||
if (path == "/") path = setupMode ? "/setup.html" : "/admin.html";
|
||||
return serveWwwFile(path);
|
||||
}
|
||||
|
||||
void redirectToSetupPage() {
|
||||
server.sendHeader("Location", String("http://") + SETUP_AP_IP.toString() + "/", true);
|
||||
server.sendHeader("Cache-Control", "no-store");
|
||||
server.send(302, "text/plain", "");
|
||||
}
|
||||
|
||||
void handleCaptiveProbe() {
|
||||
if (setupMode) {
|
||||
redirectToSetupPage();
|
||||
} else {
|
||||
server.send(204, "text/plain", "");
|
||||
}
|
||||
}
|
||||
|
||||
void handleFavicon() {
|
||||
if (!serveWwwFile("/favicon.ico")) server.send(204, "image/x-icon", "");
|
||||
}
|
||||
|
||||
void handleAdminPage() {
|
||||
if (!serveWwwFile("/admin.html")) {
|
||||
sendJson(500, jsonError("Missing /www/admin.html in LittleFS"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user