2026-06-27 20:00:17 -05:00
# include "app.h"
2026-06-27 15:58:42 -05:00
# 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 ) ;
}
2026-06-27 15:58:42 -05:00
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 ) + " } " ;
2026-06-27 15:58:42 -05:00
sendJson ( 200 , jsonOk ( out ) ) ;
}
void handleSettingsPost ( ) {
2026-06-27 20:00:17 -05:00
if ( ! authorize ( ) ) return ;
2026-06-27 15:58:42 -05:00
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 ) ;
2026-06-27 15:58:42 -05:00
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 ;
2026-06-27 15:58:42 -05:00
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 ) ;
2026-06-27 15:58:42 -05:00
applyLed ( ) ;
2026-06-27 20:00:17 -05:00
appendLog ( LOG_SECURITY_AUDIT , " settings updated " ) ;
2026-06-27 15:58:42 -05:00
sendJson ( 200 , jsonOk ( ) ) ;
}
void handleLogsGet ( ) {
2026-06-27 20:00:17 -05:00
if ( ! authorize ( ) ) return ;
2026-06-27 15:58:42 -05:00
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 ;
2026-06-27 15:58:42 -05:00
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 " ) ;
2026-06-27 15:58:42 -05:00
sendJson ( 200 , jsonOk ( ) ) ;
}
void handleCertGet ( ) {
2026-06-27 20:00:17 -05:00
if ( ! authorize ( ) ) return ;
2026-06-27 15:58:42 -05:00
sendJson ( 200 , jsonOk ( " \" certificate \" : \" " + jsonEscape ( prefString ( " cert " , " " ) ) + " \" " ) ) ;
}
void handleCertPost ( ) {
2026-06-27 20:00:17 -05:00
if ( ! authorize ( ) ) return ;
2026-06-27 15:58:42 -05:00
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 " ) ;
2026-06-27 15:58:42 -05:00
sendJson ( 200 , jsonOk ( " \" note \" : \" certificate is stored for applications that enable TLS termination \" " ) ) ;
}