182182#define SERIAL_STALE_SECONDS 1800 // 30 minutes
183183#endif
184184
185+ #ifndef MAX_HTTP_BODY_BYTES
186+ #define MAX_HTTP_BODY_BYTES 8192 // Global cap on incoming HTTP body size
187+ #endif
188+
189+ #ifndef MAX_NOTES_PER_FILE_PER_POLL
190+ #define MAX_NOTES_PER_FILE_PER_POLL 10 // Prevent long blocking notefile drains
191+ #endif
192+
185193// Calibration learning system constants
186194#ifndef CALIBRATION_LOG_PATH
187195#define CALIBRATION_LOG_PATH " /calibration_log.txt"
@@ -380,6 +388,19 @@ static bool pinMatches(const char *pin) {
380388 return strncmp (pin, gConfig .configPin , sizeof (gConfig .configPin )) == 0 ;
381389}
382390
391+ // Require that a valid admin PIN is configured and provided; respond with 403/400 on failure.
392+ static bool requireValidPin (EthernetClient &client, const char *pinValue) {
393+ if (gConfig .configPin [0 ] == ' \0 ' ) {
394+ respondStatus (client, 403 , F (" Configure admin PIN before making changes" ));
395+ return false ;
396+ }
397+ if (!pinMatches (pinValue)) {
398+ respondStatus (client, 403 , F (" Invalid PIN" ));
399+ return false ;
400+ }
401+ return true ;
402+ }
403+
383404static const char CONFIG_GENERATOR_HTML[] PROGMEM = R"HTML(
384405<!DOCTYPE html>
385406<html lang="en">
@@ -3925,11 +3946,14 @@ static const char CLIENT_CONSOLE_HTML[] PROGMEM = R"HTML(
39253946 .modal {
39263947 position: fixed;
39273948 inset: 0;
3928- background: rgba(15,23,42,0.65);
3949+ background: radial-gradient(circle at 20% 20%, rgba(99,102,241,0.12), transparent 30%),
3950+ radial-gradient(circle at 80% 30%, rgba(14,165,233,0.12), transparent 32%),
3951+ rgba(15,23,42,0.7);
39293952 display: flex;
39303953 align-items: center;
39313954 justify-content: center;
39323955 z-index: 999;
3956+ backdrop-filter: blur(4px);
39333957 }
39343958 .modal.hidden {
39353959 opacity: 0;
@@ -3938,14 +3962,36 @@ static const char CLIENT_CONSOLE_HTML[] PROGMEM = R"HTML(
39383962 .modal-card {
39393963 background: var(--surface);
39403964 border-radius: 16px;
3941- padding: 24px;
3965+ padding: 28px 26px 24px;
39423966 width: min(420px, 90%);
39433967 border: 1px solid var(--card-border);
3944- box-shadow: 0 20px 40px var(--card-shadow);
3968+ box-shadow: 0 24px 50px rgba(15,23,42,0.35);
3969+ position: relative;
39453970 }
39463971 .modal-card h2 {
39473972 margin-top: 0;
39483973 }
3974+ .modal-card .field + .field,
3975+ .modal-card .field + .actions {
3976+ margin-top: 12px;
3977+ }
3978+ .pin-hint {
3979+ display: block;
3980+ margin-top: 6px;
3981+ color: var(--muted);
3982+ font-size: 0.9rem;
3983+ }
3984+ .modal-badge {
3985+ position: absolute;
3986+ top: 14px;
3987+ right: 16px;
3988+ padding: 6px 10px;
3989+ border-radius: 999px;
3990+ font-size: 0.75rem;
3991+ background: var(--chip);
3992+ color: var(--muted);
3993+ border: 1px solid var(--card-border);
3994+ }
39493995 .hidden {
39503996 display: none !important;
39513997 }
@@ -4073,6 +4119,7 @@ static const char CLIENT_CONSOLE_HTML[] PROGMEM = R"HTML(
40734119 <div id="toast"></div>
40744120 <div id="pinModal" class="modal hidden">
40754121 <div class="modal-card">
4122+ <div class="modal-badge" id="pinSessionBadge">Session</div>
40764123 <h2 id="pinModalTitle">Set Admin PIN</h2>
40774124 <p id="pinModalDescription">Enter a 4-digit PIN to unlock configuration changes.</p>
40784125 <form id="pinForm">
@@ -4082,7 +4129,8 @@ static const char CLIENT_CONSOLE_HTML[] PROGMEM = R"HTML(
40824129 </label>
40834130 <label class="field" id="pinPrimaryGroup">
40844131 <span id="pinPrimaryLabel">PIN</span>
4085- <input type="password" id="pinInput" inputmode="numeric" pattern="\d*" maxlength="4" autocomplete="off" required>
4132+ <input type="password" id="pinInput" inputmode="numeric" pattern="\d*" maxlength="4" autocomplete="off" required placeholder="4 digits" aria-describedby="pinHint" title="Enter exactly four digits (0-9)">
4133+ <small class="pin-hint" id="pinHint">Use exactly 4 digits (0-9). The PIN is kept locally in this browser for 90 days.</small>
40864134 </label>
40874135 <label class="field hidden" id="pinConfirmGroup">
40884136 <span>Confirm PIN</span>
@@ -4113,13 +4161,40 @@ static const char CLIENT_CONSOLE_HTML[] PROGMEM = R"HTML(
41134161 });
41144162
41154163 const PIN_STORAGE_KEY = 'tankalarmPin';
4164+ const PIN_SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90-day browser retention
4165+ function loadStoredPin() {
4166+ try {
4167+ const raw = localStorage.getItem(PIN_STORAGE_KEY);
4168+ if (!raw) return null;
4169+ const parsed = JSON.parse(raw);
4170+ if (!parsed || !parsed.pin || !parsed.expiresAt) {
4171+ localStorage.removeItem(PIN_STORAGE_KEY);
4172+ return null;
4173+ }
4174+ if (Date.now() > parsed.expiresAt) {
4175+ localStorage.removeItem(PIN_STORAGE_KEY);
4176+ return null;
4177+ }
4178+ return parsed.pin;
4179+ } catch (err) {
4180+ localStorage.removeItem(PIN_STORAGE_KEY);
4181+ return null;
4182+ }
4183+ }
4184+ function storePin(pin) {
4185+ const payload = { pin, expiresAt: Date.now() + PIN_SESSION_TTL_MS };
4186+ localStorage.setItem(PIN_STORAGE_KEY, JSON.stringify(payload));
4187+ }
4188+ function clearStoredPin() {
4189+ localStorage.removeItem(PIN_STORAGE_KEY);
4190+ }
41164191 const state = {
41174192 data: null,
41184193 selected: null
41194194 };
41204195
41214196 const pinState = {
4122- value: sessionStorage.getItem(PIN_STORAGE_KEY ) || null,
4197+ value: loadStoredPin( ) || null,
41234198 configured: false,
41244199 mode: 'unlock'
41254200 };
@@ -4187,7 +4262,7 @@ static const char CLIENT_CONSOLE_HTML[] PROGMEM = R"HTML(
41874262
41884263 function invalidatePin() {
41894264 pinState.value = null;
4190- sessionStorage.removeItem(PIN_STORAGE_KEY );
4265+ clearStoredPin( );
41914266 setFormDisabled(true);
41924267 }
41934268
@@ -4210,17 +4285,20 @@ static const char CLIENT_CONSOLE_HTML[] PROGMEM = R"HTML(
42104285 pinEls.description.textContent = 'Choose a 4-digit PIN to secure configuration changes.';
42114286 pinEls.confirmGroup.classList.remove('hidden');
42124287 pinEls.cancel.classList.add('hidden');
4288+ document.getElementById('pinSessionBadge').textContent = 'Required';
42134289 } else if (mode === 'change') {
42144290 pinEls.title.textContent = 'Change Admin PIN';
42154291 pinEls.description.textContent = 'Enter your current PIN and the new PIN you would like to use.';
42164292 pinEls.currentGroup.classList.remove('hidden');
42174293 pinEls.confirmGroup.classList.remove('hidden');
42184294 pinEls.primaryLabel.textContent = 'New PIN';
42194295 pinEls.cancel.classList.remove('hidden');
4296+ document.getElementById('pinSessionBadge').textContent = 'Secured';
42204297 } else {
42214298 pinEls.title.textContent = 'Enter Admin PIN';
4222- pinEls.description.textContent = 'Enter the admin PIN to unlock configuration controls.';
4299+ pinEls.description.textContent = 'Enter the admin PIN to unlock configuration controls. We keep it in this browser for 90 days. ';
42234300 pinEls.cancel.classList.remove('hidden');
4301+ document.getElementById('pinSessionBadge').textContent = 'Locked';
42244302 }
42254303 pinEls.modal.classList.remove('hidden');
42264304 setFormDisabled(true);
@@ -4290,7 +4368,7 @@ static const char CLIENT_CONSOLE_HTML[] PROGMEM = R"HTML(
42904368
42914369 const result = await requestPin(payload);
42924370 pinState.value = pinToStore;
4293- sessionStorage.setItem(PIN_STORAGE_KEY, pinToStore);
4371+ storePin( pinToStore);
42944372 hidePinModal();
42954373 setFormDisabled(false);
42964374 showToast((result && result.message) || 'PIN updated');
@@ -4323,6 +4401,7 @@ static const char CLIENT_CONSOLE_HTML[] PROGMEM = R"HTML(
43234401 showPinModal('unlock');
43244402 }
43254403 } else {
4404+ storePin(pinState.value); // Refresh TTL on activity
43264405 setFormDisabled(false);
43274406 hidePinModal();
43284407 }
@@ -4781,7 +4860,7 @@ static void scheduleNextDailyEmail();
47814860static void scheduleNextViewerSummary ();
47824861static void initializeEthernet ();
47834862static void handleWebRequests ();
4784- static bool readHttpRequest (EthernetClient &client, String &method, String &path, String &body, size_t &contentLength);
4863+ static bool readHttpRequest (EthernetClient &client, String &method, String &path, String &body, size_t &contentLength, bool &bodyTooLarge );
47854864static void respondHtml (EthernetClient &client, const String &body);
47864865static void respondJson (EthernetClient &client, const String &body, int status = 200 );
47874866static void respondStatus (EthernetClient &client, int status, const String &message);
@@ -4846,19 +4925,26 @@ static bool checkSmsRateLimit(TankRecord *rec);
48464925static void publishViewerSummary ();
48474926static double computeNextAlignedEpoch (double epoch, uint8_t baseHour, uint32_t intervalSeconds);
48484927static String getQueryParam (const String &query, const char *key);
4928+ static bool requireValidPin (EthernetClient &client, const char *pinValue);
48494929
48504930static void handleRefreshPost (EthernetClient &client, const String &body) {
48514931 char clientUid[64 ] = {0 };
4932+ const char *pinValue = nullptr ;
48524933 if (body.length () > 0 ) {
4853- DynamicJsonDocument doc (128 );
4934+ DynamicJsonDocument doc (192 );
48544935 if (deserializeJson (doc, body) == DeserializationError::Ok) {
48554936 const char *uid = doc[" client" ] | " " ;
48564937 if (uid && *uid) {
48574938 strlcpy (clientUid, uid, sizeof (clientUid));
48584939 }
4940+ pinValue = doc[" pin" ].as <const char *>();
48594941 }
48604942 }
48614943
4944+ if (!requireValidPin (client, pinValue)) {
4945+ return ;
4946+ }
4947+
48624948 if (clientUid[0 ]) {
48634949 Serial.print (F (" Manual refresh requested for client " ));
48644950 Serial.println (clientUid);
@@ -5537,7 +5623,7 @@ static bool loadConfig(ServerConfig &cfg) {
55375623 } else {
55385624 cfg.webRefreshSeconds = 21600 ;
55395625 }
5540- cfg.useStaticIp = doc[" useStaticIp" ].is <bool >() ? doc[" useStaticIp" ].as <bool >() : true ;
5626+ cfg.useStaticIp = doc[" useStaticIp" ].is <bool >() ? doc[" useStaticIp" ].as <bool >() : false ;
55415627 cfg.smsOnHigh = doc[" smsOnHigh" ].is <bool >() ? doc[" smsOnHigh" ].as <bool >() : true ;
55425628 cfg.smsOnLow = doc[" smsOnLow" ].is <bool >() ? doc[" smsOnLow" ].as <bool >() : true ;
55435629 cfg.smsOnClear = doc[" smsOnClear" ].is <bool >() ? doc[" smsOnClear" ].as <bool >() : false ;
@@ -5704,7 +5790,7 @@ static void initializeNotecard() {
57045790}
57055791
57065792static void ensureTimeSync () {
5707- if (gLastSyncedEpoch <= 0.0 || millis () - gLastSyncMillis > 6UL * 60UL * 60UL * 1000UL ) {
5793+ if (gLastSyncedEpoch <= 0.0 || ( uint32_t )( millis () - gLastSyncMillis ) > 6UL * 60UL * 60UL * 1000UL ) {
57085794 J *req = notecard.newRequest (" card.time" );
57095795 if (!req) {
57105796 return ;
@@ -5736,7 +5822,7 @@ static double currentEpoch() {
57365822 if (gLastSyncedEpoch <= 0.0 ) {
57375823 return 0.0 ;
57385824 }
5739- unsigned long delta = millis () - gLastSyncMillis ;
5825+ uint32_t delta = ( uint32_t )( millis () - gLastSyncMillis ); // Handles millis() rollover
57405826 return gLastSyncedEpoch + (double )delta / 1000.0 ;
57415827}
57425828
@@ -5826,13 +5912,20 @@ static void handleWebRequests() {
58265912 String path;
58275913 String body;
58285914 size_t contentLength = 0 ;
5915+ bool bodyTooLarge = false ;
58295916
5830- if (!readHttpRequest (client, method, path, body, contentLength)) {
5917+ if (!readHttpRequest (client, method, path, body, contentLength, bodyTooLarge )) {
58315918 respondStatus (client, 400 , F (" Bad Request" ));
58325919 client.stop ();
58335920 return ;
58345921 }
58355922
5923+ if (bodyTooLarge) {
5924+ respondStatus (client, 413 , F (" Payload Too Large" ));
5925+ client.stop ();
5926+ return ;
5927+ }
5928+
58365929 if (method == " GET" && path == " /" ) {
58375930 sendDashboard (client);
58385931 } else if (method == " GET" && path == " /client-console" ) {
@@ -5907,11 +6000,12 @@ static void handleWebRequests() {
59076000 client.stop ();
59086001}
59096002
5910- static bool readHttpRequest (EthernetClient &client, String &method, String &path, String &body, size_t &contentLength) {
6003+ static bool readHttpRequest (EthernetClient &client, String &method, String &path, String &body, size_t &contentLength, bool &bodyTooLarge ) {
59116004 method = " " ;
59126005 path = " " ;
59136006 contentLength = 0 ;
59146007 body = " " ;
6008+ bodyTooLarge = false ;
59156009
59166010 String line;
59176011 bool firstLine = true ;
@@ -5952,6 +6046,10 @@ static bool readHttpRequest(EthernetClient &client, String &method, String &path
59526046 headerValue.trim ();
59536047 if (headerKey.equalsIgnoreCase (" Content-Length" )) {
59546048 contentLength = headerValue.toInt ();
6049+ if (contentLength > MAX_HTTP_BODY_BYTES) {
6050+ bodyTooLarge = true ;
6051+ contentLength = MAX_HTTP_BODY_BYTES;
6052+ }
59556053 }
59566054 }
59576055 }
@@ -5972,6 +6070,10 @@ static bool readHttpRequest(EthernetClient &client, String &method, String &path
59726070 body += c;
59736071 readBytes++;
59746072 }
6073+ if (readBytes >= MAX_HTTP_BODY_BYTES) {
6074+ bodyTooLarge = true ;
6075+ break ;
6076+ }
59756077 }
59766078 }
59776079
@@ -6393,12 +6495,17 @@ static void handlePinPost(EthernetClient &client, const String &body) {
63936495}
63946496
63956497static void handleRelayPost (EthernetClient &client, const String &body) {
6396- DynamicJsonDocument doc (512 );
6498+ DynamicJsonDocument doc (640 );
63976499 if (deserializeJson (doc, body)) {
63986500 respondStatus (client, 400 , F (" Invalid JSON" ));
63996501 return ;
64006502 }
64016503
6504+ const char *pinValue = doc[" pin" ].as <const char *>();
6505+ if (!requireValidPin (client, pinValue)) {
6506+ return ;
6507+ }
6508+
64026509 const char *clientUid = doc[" clientUid" ].as <const char *>();
64036510 if (!clientUid || strlen (clientUid) == 0 ) {
64046511 respondStatus (client, 400 , F (" Missing clientUid" ));
@@ -6510,7 +6617,8 @@ static void pollNotecard() {
65106617}
65116618
65126619static void processNotefile (const char *fileName, void (*handler)(JsonDocument &, double )) {
6513- while (true ) {
6620+ uint8_t processed = 0 ;
6621+ while (processed < MAX_NOTES_PER_FILE_PER_POLL) {
65146622 J *req = notecard.newRequest (" note.get" );
65156623 if (!req) {
65166624 return ;
@@ -6542,6 +6650,10 @@ static void processNotefile(const char *fileName, void (*handler)(JsonDocument &
65426650 }
65436651
65446652 notecard.deleteResponse (rsp);
6653+ processed++;
6654+
6655+ // Yield to keep the loop responsive and allow watchdog kicks elsewhere
6656+ delay (1 );
65456657 }
65466658}
65476659
0 commit comments