Skip to content

fix(web): bind six dead controls programmatically, restrict client UIDs, clamp config inputs, add page checker to CI - #315

Merged
dorkmo merged 3 commits into
masterfrom
website-handlers-and-clamps
Sep 15, 2026
Merged

dorkmo merged 3 commits into
masterfrom
website-handlers-and-clamps

Conversation

@dorkmo

@dorkmo dorkmo commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Stacked PR. Base branch is website-style-consistency (#314) because the server's pages are single very long source lines, so two PRs that touch the same page conflict textually. Merge order: #314#315#316#317. This PR's diff shows only its own changes.

Summary

Functional fix for the six dead page controls (master review S-W02), a server-side character set for client UIDs, two config-generator input clamps that were silently exceeding client firmware limits, and a CI check so the dead-handler class of bug cannot come back.

Six controls that threw ReferenceError on click

Every page script runs inside (async () => { ... })();. A function name(){} declared there is not visible to an inline onclick="name()", so the click threw ReferenceError: name is not defined and nothing else reported it:

Page Control Handler
/site-config Expect Update, Remove Client (plus Edit Config and the click-to-copy UID, same pattern) expectUpdate, deleteClient, copyUid
/calibration per-sensor Reset, sensor-name link (view data points) resetCalibration, viewTankPoints
/transmission-log Cancel on a pending config cancelPendingConfig
/historical custom date range start/end (onchange) renderLevelChart
/ (dashboard), /client-console Update, Snooze Reminders, Clear Relay, Remove Client; Edit Config(uration), Approve Deletion (already working, same UID splice; second commit) requestUpdate, snoozeAlarm, clearRelays, deleteClient, approveDeletion
/config-generator cloud client picker rows (already working, same UID splice; third commit) fetchClientConfig

Those attributes also spliced client UIDs into JavaScript source (onclick="resetCalibration('${cal.clientUid}',…)"), so a UID containing ' could run code the moment the handlers started working. The controls now carry their UID or key in HTML-escaped data-* attributes and are bound by one delegated click/change listener inside the page IIFE; the handler functions stay closure-local and no inline handler or window.* export is needed for them. The second commit gives the dashboard and client-console controls the same treatment (encodeURIComponent() does not encode a single quote either, so the Edit Config links were converted as well) and HTML-escapes the client console's UID readout. No page renders a client UID into JavaScript source any more. The third commit makes the attribute values themselves safe: six pages implemented escapeHtml() by serialising a text node, which does not escape quotes, so they now share the five-character escaper (& < > " ') the dashboard already had.

Client UIDs are restricted at the source

isValidClientUid() (telemetry, alarm and daily ingestion) accepted anything after dev: up to 47 characters. It now also requires letters, digits, :, _, - or ., which covers Blues device UIDs (IMEI or hex), so every UID the server stores is safe to embed in attributes, JSON, log lines and file names. handleConfigPost applies the same check to operator-submitted UIDs before storing a config snapshot, and a request that carries a config without a usable client UID is answered with 400 instead of a silent 200; findOrCreateClientMetadata() refuses to create metadata for a malformed UID, which covers the OTA acknowledgement, location reply and /api/ota/expect paths (every caller already handles a null result), and the calibration POST/DELETE handlers, the client serial-log buffer (serial_log/serial_ack notes) and the unload handler reject a malformed UID before any lookup, write or notification (those snapshots are rendered on the dashboard as "Configured Client" cards), the bare dev: prefix is rejected, and records persisted before this validation existed (the sensor registry, calibrations and their summaries, hot-tier history and FTP cache summaries, client config snapshots, client metadata) are validated when they are loaded at boot, on the complete stored value before it is copied into the fixed-size field (so an overlong id cannot pass as its truncated prefix), and skipped (empty ids included) with a serial warning if their UID is not a valid device UID; calibration log entries with an invalid UID are omitted from /api/calibration, and a registry that dropped a record is marked dirty so it is re-saved clean. Rejected UIDs are logged through a sanitising printer (printable ASCII, \xNN otherwise, 64 chars max) so a stray control byte cannot forge a log line.

Config generator clamps

  • Sample Minutes max 14401092, in both the input's max attribute and the collectConfig clamp (which now parses with Number() and truncates, so exponent input such as 1e3 is read as 1000, not 1). The client stores sampleSeconds in a uint16_t; 1440 min = 86 400 s does not fit, so is<uint16_t>() failed and the client quietly fell back to its default interval (CLAUDE review M-08).
  • Momentary relay durations max 8640065535, and collectConfig now clamps each of the four values to 0…65535 (parsed with Number() and truncated). The client parses relayMomentaryDurations[] with as<uint16_t>(), which yields 0 for anything above 65535, so the firmware-side > 86400 guard could never engage (CLAUDE review M-06).

check_web_pages.py + CI job

TankAlarm-112025-Server-BluesOpta/check_web_pages.py extracts every PROGMEM page from the server and viewer sketches (joining the )HTML" R"HTML( segments), runs node --check on each <script> block, and asserts that every function called from an inline on<event> attribute resolves to a depth-0 function fn( or a window.fn = assignment that actually executes on load:

  • every call in an attribute value is checked (onclick="a(); b()", onclick="if (x) b()"), not only the first, after string literals, comments and regex literals in the value are blanked (alert('missing()') calls only alert); method calls (a.b()), callable browser globals (alert, open, … but not location) and ${…} template splices (which run at render time; strings, regex literals and nested templates inside a splice are lexed so a { in a string or a regex cannot swallow the handler) are skipped;
  • any on<event> attribute is matched case-insensitively (onClick), quoted or unquoted (onclick=missing()), with optional whitespace around =, including attributes inside JS template strings;
  • a window.fn = counts only when it starts a statement (not if (x) window.fn = fn;, else window.fn = fn;, x && (window.fn = fn), y ? window.fn = fn : 0), every enclosing function body is an IIFE that is a statement of its own (not false && (() => {…})()) or a load/DOMContentLoaded listener, and no conditional block (if/else/for/while/switch/catch) encloses it; an assignment inside an uncalled function, an ordinary callback, an expression-bodied arrow (.then(() => window.fn = fn)) or a branch that may not run does not;
  • an assignment that follows an unconditional return/throw in the same block is unreachable and does not count;
  • a depth-0 function name( counts only when it starts a statement; a named function expression (const holder = function dead(){}) does not expose dead;
  • the assigned value must be callable: a function expression, an arrow, or the name of a function declared in the assignment's own scope or an enclosing one; window.dead = undefined, a plain variable, or a function declared inside some other function does not count;
  • the tokeniser skips strings, template literals (nested ${}), comments and regex literals, including regex literals after keywords such as return;
  • it fails closed: a missing node (for the scan and for --selftest), a missing sketch, or a sketch yielding no pages is a failure;
  • --selftest runs 60 synthetic pages covering each of these rules.

A new check-web-pages job in arduino-ci-112025.yml (with actions/setup-node) runs the self-test and then the scan on every push and pull request; build-firmware depends on it, so firmware binaries are not built and committed while a page check fails. The workflow's pull_request trigger no longer filters on the base branch, so stacked PRs like this one get the same checks.

Before this PR the scan reported exactly the six handlers above; after it, 17 pages checked, 0 problem(s) (the stylesheet page has nothing to check).

Verification

  • Browser (loopback fixture, pages extracted from this branch): every converted control runs its handler through the delegated listener with the right payload (calibration Reset → DELETE with the row's UID and index; sensor link → its toast; Expect Update → POST /api/ota/expect; Remove Client → DELETE /api/client; Cancel → POST /api/config/cancel; custom-range dates → chart re-render; dashboard Update / Snooze / Clear Relay / Remove Client → their four endpoints; client console Approve Deletion → DELETE /api/client) with zero error events. A fixture row whose UID contains ');alert(1);// renders as inert text, the handler receives it verbatim and alert is never called.
  • Clamps: Sample Minutes 5000sampleSeconds: 65520; relay durations 99999 / -5 / 3600 / abc[65535, 0, 3600, 0] in the downloaded JSON.
  • Compile at arduino:mbed_opta 4.5.0, --warnings all: 1,022,836 B (52%) flash on this stacked branch (+3040 B over the Website: unify page chrome and fix layout/style inconsistencies #314 build), 360,720 B (68%) static RAM (unchanged); warning set identical to the master build (line numbers aside). CI's compile-check and check-web-pages both pass on this PR.
  • check_web_pages.py --selftest: 60/60; the scan: 17 pages, 0 problems (the stylesheet page has no script and no handlers).

Out of scope

The dashboard's Clear Relay sends the sensor's position in the /api/clients ts[] array, which the client applies as its monitor-array index; a client whose sensors report out of order can clear the wrong monitor. That is pre-existing (the inline handler sent the same value) and needs a server/client contract change, so it is tracked as S-T06 in the TODO rather than fixed here.
The client-side parse (as<uint16_t>() with a dead > 86400 guard) is a firmware change and stays with the client items in the TODO. S-W01 (config round-trip) and S-W03 (calibration identity) follow in #316.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings September 14, 2026 14:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical and moderate issues remain in the page checker and CI enforcement.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR fixes six dead inline handlers, clamps configuration values to firmware limits, and adds embedded-page validation to CI.

Changes:

  • Exports six handlers to window.
  • Clamps sample intervals and relay durations.
  • Adds page extraction, syntax, and handler checks to CI.
File summaries
File Summary Final review comments
TankAlarm-112025-Server-BluesOpta/TankAlarm-112025-Server-BluesOpta.ino Handler exports and configuration clamps None
TankAlarm-112025-Server-BluesOpta/check_web_pages.py Embedded-page validation Critical (1 vote): regex detection mishandles literals after keywords. Moderate (3 votes): event allow-list omits valid inline events. Moderate (2 votes): missing sketches do not fail CI. Moderate (1 vote): missing Node fails open. Moderate (1 vote): zero extracted pages can pass silently.
.github/workflows/arduino-ci-112025.yml CI integration Moderate (1 vote): build-firmware does not depend on the page-check job.
Review details

Suppressed comments (3)

.github/workflows/arduino-ci-112025.yml:231

  • This job is not included in build-firmware's needs list, so on a push a failing page check can run in parallel while the firmware build still succeeds and commits artifacts. If this check is meant to prevent the regression from reaching the main branch/release path, make the artifact-producing job depend on check-web-pages (or otherwise enforce it as a required status).
  check-web-pages:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout repository
        uses: actions/checkout@v5

      - name: Check embedded web pages (script syntax, inline handler reachability)
        run: python3 TankAlarm-112025-Server-BluesOpta/check_web_pages.py

TankAlarm-112025-Server-BluesOpta/check_web_pages.py:178

  • When Node is unavailable, this checker only prints a warning and still returns success, while the workflow does not install or pin Node. That makes the syntax-check portion fail open and can report a green CI result with zero JavaScript validation; missing required tooling should fail the job or be provisioned explicitly.
    node = shutil.which('node')
    if not node:
        print('WARNING: node not found on PATH; skipping syntax checks', file=sys.stderr)

TankAlarm-112025-Server-BluesOpta/check_web_pages.py:187

  • If PAGE_RE stops matching because the sketch declaration format changes, extract_pages(sk) can return no pages and the loop simply does nothing; the checker then reports success with 0 pages checked. Assert that each required sketch yielded at least one page so parser drift cannot disable the check silently.
        for name, page in extract_pages(sk).items():
            if '<script' not in page and 'on' not in page:
                continue
  • Files reviewed: 2/3 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread TankAlarm-112025-Server-BluesOpta/check_web_pages.py Outdated
Comment thread TankAlarm-112025-Server-BluesOpta/check_web_pages.py Outdated
Comment thread TankAlarm-112025-Server-BluesOpta/check_web_pages.py
@dorkmo
dorkmo force-pushed the website-handlers-and-clamps branch from e17e726 to a8ca057 Compare September 14, 2026 14:31
@dorkmo
dorkmo changed the base branch from master to website-style-consistency September 14, 2026 14:31
dorkmo added a commit that referenced this pull request Sep 14, 2026
…316, #317

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dorkmo
dorkmo force-pushed the website-handlers-and-clamps branch from a8ca057 to 53c5f50 Compare September 14, 2026 14:33
@dorkmo
dorkmo requested a balanced review from Copilot September 14, 2026 15:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new handler checker has false negatives that can allow unreachable inline handlers to pass CI.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

TankAlarm-112025-Server-BluesOpta/check_web_pages.py:34

  • This pattern does not cover every inline on* handler as documented: it requires = with no surrounding whitespace and hard-codes a subset of event names. For example, both onclick = "missing()" and onpointerdown="missing()" currently produce no failure, allowing the same unreachable-handler regression to bypass CI. Match arbitrary on... attributes and optional whitespace around = instead.
HANDLER_RE = re.compile(
    r'\bon(?:click|change|input|submit|keyup|keydown|keypress|load|blur|focus|mouseenter|mouseleave|mouseover|mouseout)'
    r'=\\?["\']\s*(?:window\.)?([A-Za-z_$][\w$]*)\s*\(')
  • Files reviewed: 2/3 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread TankAlarm-112025-Server-BluesOpta/check_web_pages.py Outdated
@dorkmo
dorkmo force-pushed the website-handlers-and-clamps branch from 53c5f50 to 878684e Compare September 14, 2026 16:22
dorkmo added a commit that referenced this pull request Sep 14, 2026
… 1-based

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dorkmo
dorkmo requested a balanced review from Copilot September 14, 2026 18:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Newly activated handlers permit stored script injection, and the checker has reachability gaps.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 2/3 changed files
  • Comments generated: 4
  • Review effort level: Balanced

@@ -2248,7 +2248,7 @@ funct)HTML" R"HTML(ion populateSensorDropdowns(){const sensorSelect = document.g
async funct)HTML" R"HTML(ion loadCalibrationData(){try{const response = await fetch('/api/calibration');if(!response.ok)throw new Error('Failed to load calibration data');const data = await response.json();calibrations = data.calibrations || [];calibrationLogs = data.logs || [];updateCalibrationStats();updateCalibrationTable();updateCalibrationLog();}catch(err){console.error('Error loading calibration data:',err);}}
funct)HTML" R"HTML(ion updateCalibrationStats(){const total = sensors.length > 0 ? new Set(sensors.map(t => `${t.client}:${t.sensorIndex}`)).size:0;const calibrated = calibrations.filter(c => c.hasLearnedCalibration).length;const learning = calibrations.filter(c => !c.hasLearnedCalibration && c.entryCount > 0).length;const uncalibrated = total - calibrated - learning;document.getElementById('statTotalTanks').textContent = total;document.getElementById('statCalibrated').textContent = calibrated;document.getElementById('statLearning').textContent = learning;document.getElementById('statUncalibrated').textContent = Math.max(0,uncalibrated);}
funct)HTML" R"HTML(ion updateCalibrationTable(){const tbody = document.getElementById('calibrationTableBody');tbody.innerHTML = '';if(calibrations.length === 0){tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;color:var(--muted);">No calibration data yet. Add readings to start learning.</td></tr>';return;}calibrations.forEach(cal =>{const tr = document.createElement('tr');const sensorInfo = sensors.find(t => t.client === cal.clientUid && t.sensorIndex === cal.sensorIndex);const sensorName = sensorInfo ? `${sensorInfo.label || 'Sensor ' + cal.sensorIndex}${sensorInfo.userNumber?' #'+sensorInfo.userNumber:''}`:`Sensor ${cal.sensorIndex}`;const site = sensorInfo ? sensorInfo.site:'--';let statusClass = 'uncalibrated';let statusText = 'Uncalibrated';let warnings = [];if(cal.hasLearnedCalibration){statusClass = 'calibrated';statusText = cal.hasTempCompensation ? 'Calibrated+Temp' : 'Calibrated';if(cal.rSquared < 0.95){warnings.push('Low R&sup2; fit (<95%)');}if(cal.entryCount === 2){warnings.push('Only 2 data points');}}else if(cal.entryCount > 0){statusClass = 'learning';statusText = 'Learning';if(cal.entryCount === 1){warnings.push('Need 1 more point');}}const sensorRange = cal.maxSensorMa - cal.minSensorMa;const levelRange = cal.maxLevelInches - cal.minLevelInches;if(cal.hasLearnedCalibration && sensorRange < 4){warnings.push('Narrow sensor range (<4mA)');}let driftText = '--';let driftClass = 'low';if(cal.hasLearnedCalibration && cal.originalMaxValue > 0){const originalSlope = cal.originalMaxValue / 16.0;const drift = Math.abs((cal.learnedSlope - originalSlope)/ originalSlope * 100);driftText = drift.toFixed(1)+ '%';if(drift > 10)driftClass = 'high';else if(drift > 5)driftClass = 'medium';}let tempCoefText = '--';if(cal.hasTempCompensation && cal.learnedTempCoef !== undefined){tempCoefText = cal.learnedTempCoef.toFixed(4) + ' '+getSensorUnit(cal.clientUid,cal.sensorIndex)+'/°F';}else if(cal.tempEntryCount > 0){tempCoefText = `(${cal.tempEntryCount} pts)`;}let rangeText = '--';if(cal.entryCount >= 1){rangeText = `${cal.minSensorMa.toFixed(1)}-${cal.maxSensorMa.toFixed(1)} mA`;}let warningHtml = '';if(warnings.length > 0){warningHtml = `<span class="quality-warning" title="${warnings.join(', ')}">&#x26A0;&#xFE0F;</span>`;}const sensorKey = `${cal.clientUid}:${cal.sensorIndex}`;tr.innerHTML = ` <td><a href="#" class="sensor-link" onclick="viewTankPoints('${sensorKey}');return false;" title="Click to view data points">${escapeHtml(sensorName)}</a>${warningHtml}</td><td>${escapeHtml(site)}</td><td><span class="calibration-status ${statusClass}">${statusText}</span></td><td title="Sensor range: ${rangeText}">${cal.entryCount}</td><td>${cal.hasLearnedCalibration ?(cal.rSquared * 100).toFixed(1)+ '%':'--'}</td><td>${cal.hasLearnedCalibration ? cal.learnedSlope.toFixed(3)+ ' '+getSensorUnit(cal.clientUid,cal.sensorIndex)+'/mA':'--'}</td><td title="Temperature coefficient (${getSensorUnit(cal.clientUid,cal.sensorIndex)} per °F deviation from 70°F)">${tempCoefText}</td><td><span class="drift-indicator ${driftClass}">${driftText}</span></td><td>${formatEpoch(cal.lastCalibrationEpoch)}</td><td><button class="btn-reset" onclick="resetCalibration('${cal.clientUid}',${cal.sensorIndex})" title="Reset calibration for this sensor">Reset</button></td> `;tbody.appendChild(tr);});}
funct)HTML" R"HTML(ion updateCalibrationLog(){const tbody = document.getElementById('logTableBody');const filter = document.getElementById('logSensorFilter').value;tbody.innerHTML = '';let filtered = calibrationLogs;if(filter){const [clientUid,sensorIdx] = filter.split(':');filtered = calibrationLogs.filter(log => log.clientUid === clientUid && log.sensorIndex === parseInt(sensorIdx));}if(filtered.length === 0){tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--muted);">No calibration entries found.</td></tr>';return;}filtered.sort((a,b)=> b.timestamp - a.timestamp);filtered.forEach(log =>{const tr = document.createElement('tr');const sensorInfo = sensors.find(t => t.client === log.clientUid && t.sensorIndex === log.sensorIndex);const sensorName = sensorInfo ? `${sensorInfo.site} - ${sensorInfo.label || 'Sensor ' + log.sensorIndex}${sensorInfo.userNumber?' #'+sensorInfo.userNumber:''}`:`Sensor ${log.sensorIndex}`;const isValidReading = log.sensorReading >= 4 && log.sensorReading <= 20;const sensorDisplay = isValidReading ? log.sensorReading.toFixed(2)+ ' mA':(log.sensorReading ? `${log.sensorReading.toFixed(2)} mA (out of range)`:'-- (out of range)');const tempDisplay = log.temperatureF !== undefined && log.temperatureF !== null ? log.temperatureF.toFixed(1)+ '°F':'--';tr.innerHTML = ` <td>${formatEpoch(log.timestamp)}</td><td>${escapeHtml(sensorName)}</td><td title="${isValidReading ? '':'Not used for calibration(outside 4-20mA range)'}">${sensorDisplay}</td><td>${formatLevel(log.verifiedLevelInches,getSensorUnit(log.clientUid,log.sensorIndex))}</td><td>${tempDisplay}</td><td>${escapeHtml(log.notes || '--')}</td> `;if(!isValidReading){tr.style.opacity = '0.6';}tbody.appendChild(tr);});}document.getElementById('calibrationForm').addEventListener('submit',async(e)=>{e.preventDefault();const sensorKey = document.getElementById('sensorSelect').value;if(!sensorKey){showToast('Please select a sensor',true);return;}const [clientUid,sensorIndex] = sensorKey.split(':');const tank = sensors.find(t => `${t.client}:${t.sensorIndex}` === sensorKey);const isTankMode=document.getElementById('levelInputTank').style.display!=='none';let totalValue;if(isTankMode){const levelFeet = parseInt(document.getElementById('levelFeet').value)|| 0;const levelInches = parseFloat(document.getElementById('levelInches').value)|| 0;totalValue = levelFeet * 12 + levelInches;}else{totalValue = parseFloat(document.getElementById('levelValue').value)||0;}const timestampInput = document.getElementById('readingTimestamp').value;const note)HTML" R"HTML(s = document.getElementById('notes').value.trim();if(totalValue < 0){showToast('Invalid level value',true);return;}const payload ={clientUid:clientUid,sensorIndex:parseInt(sensorIndex),verifiedLevelInches:totalValue,notes:notes};if(tank && tank.sensorMa && tank.sensorMa >= 4 && tank.sensorMa <= 20){payload.sensorReading = tank.sensorMa;}if(timestampInput){payload.timestamp = Math.floor(new Date(timestampInput).getTime()/ 1000);}try{const response = await fetch('/api/calibration',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});if(!response.ok){const text = await response.text();throw new Error(text || 'Failed to submit calibration');}showToast('Calibration reading submitted successfully');document.getElementById('calibrationForm').reset();loadCalibrationData();}catch(err){console.error('Error submitting calibration:',err);showToast(err.message || 'Failed to submit calibration',true);}});document.getElementById('logSensorFilter').addEventListener('change',updateCalibrationLog);const now = new Date();now.setMinutes(now.getMinutes()- now.getTimezoneOffset());document.getElementById('readingTimestamp').value = now.toISOString().slice(0,16);await loadSensors();await loadCalibrationData();setInterval(loadCalibrationData,30000);funct)HTML" R"HTML(ion viewTankPoints(sensorKey){document.getElementById('logSensorFilter').value = sensorKey;updateCalibrationLog();document.getElementById('logTableBody').closest('.card').scrollIntoView({behavior:'smooth',block:'start'});showToast('Showing data points for selected tank');}async funct)HTML" R"HTML(ion resetCalibration(clientUid,sensorIndex){if(!confirm(`Reset calibration for sensor ${sensorIndex}? This will delete all calibration data for this sensor.`)){return;}try{const response = await fetch('/api/calibration',{method:'DELETE',headers:{'Content-Type':'application/json'},body:JSON.stringify({clientUid:clientUid,sensorIndex:sensorIndex})});if(!response.ok){const text = await response.text();throw new Error(text || 'Failed to reset calibration');}showToast('Calibration reset successfully');loadCalibrationData();}catch(err){console.error('Error resetting calibration:',err);showToast(err.message || 'Failed to reset calibration',true);}}})();})();
funct)HTML" R"HTML(ion updateCalibrationLog(){const tbody = document.getElementById('logTableBody');const filter = document.getElementById('logSensorFilter').value;tbody.innerHTML = '';let filtered = calibrationLogs;if(filter){const [clientUid,sensorIdx] = filter.split(':');filtered = calibrationLogs.filter(log => log.clientUid === clientUid && log.sensorIndex === parseInt(sensorIdx));}if(filtered.length === 0){tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--muted);">No calibration entries found.</td></tr>';return;}filtered.sort((a,b)=> b.timestamp - a.timestamp);filtered.forEach(log =>{const tr = document.createElement('tr');const sensorInfo = sensors.find(t => t.client === log.clientUid && t.sensorIndex === log.sensorIndex);const sensorName = sensorInfo ? `${sensorInfo.site} - ${sensorInfo.label || 'Sensor ' + log.sensorIndex}${sensorInfo.userNumber?' #'+sensorInfo.userNumber:''}`:`Sensor ${log.sensorIndex}`;const isValidReading = log.sensorReading >= 4 && log.sensorReading <= 20;const sensorDisplay = isValidReading ? log.sensorReading.toFixed(2)+ ' mA':(log.sensorReading ? `${log.sensorReading.toFixed(2)} mA (out of range)`:'-- (out of range)');const tempDisplay = log.temperatureF !== undefined && log.temperatureF !== null ? log.temperatureF.toFixed(1)+ '°F':'--';tr.innerHTML = ` <td>${formatEpoch(log.timestamp)}</td><td>${escapeHtml(sensorName)}</td><td title="${isValidReading ? '':'Not used for calibration(outside 4-20mA range)'}">${sensorDisplay}</td><td>${formatLevel(log.verifiedLevelInches,getSensorUnit(log.clientUid,log.sensorIndex))}</td><td>${tempDisplay}</td><td>${escapeHtml(log.notes || '--')}</td> `;if(!isValidReading){tr.style.opacity = '0.6';}tbody.appendChild(tr);});}document.getElementById('calibrationForm').addEventListener('submit',async(e)=>{e.preventDefault();const sensorKey = document.getElementById('sensorSelect').value;if(!sensorKey){showToast('Please select a sensor',true);return;}const [clientUid,sensorIndex] = sensorKey.split(':');const tank = sensors.find(t => `${t.client}:${t.sensorIndex}` === sensorKey);const isTankMode=document.getElementById('levelInputTank').style.display!=='none';let totalValue;if(isTankMode){const levelFeet = parseInt(document.getElementById('levelFeet').value)|| 0;const levelInches = parseFloat(document.getElementById('levelInches').value)|| 0;totalValue = levelFeet * 12 + levelInches;}else{totalValue = parseFloat(document.getElementById('levelValue').value)||0;}const timestampInput = document.getElementById('readingTimestamp').value;const note)HTML" R"HTML(s = document.getElementById('notes').value.trim();if(totalValue < 0){showToast('Invalid level value',true);return;}const payload ={clientUid:clientUid,sensorIndex:parseInt(sensorIndex),verifiedLevelInches:totalValue,notes:notes};if(tank && tank.sensorMa && tank.sensorMa >= 4 && tank.sensorMa <= 20){payload.sensorReading = tank.sensorMa;}if(timestampInput){payload.timestamp = Math.floor(new Date(timestampInput).getTime()/ 1000);}try{const response = await fetch('/api/calibration',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});if(!response.ok){const text = await response.text();throw new Error(text || 'Failed to submit calibration');}showToast('Calibration reading submitted successfully');document.getElementById('calibrationForm').reset();loadCalibrationData();}catch(err){console.error('Error submitting calibration:',err);showToast(err.message || 'Failed to submit calibration',true);}});document.getElementById('logSensorFilter').addEventListener('change',updateCalibrationLog);const now = new Date();now.setMinutes(now.getMinutes()- now.getTimezoneOffset());document.getElementById('readingTimestamp').value = now.toISOString().slice(0,16);await loadSensors();await loadCalibrationData();setInterval(loadCalibrationData,30000);funct)HTML" R"HTML(ion viewTankPoints(sensorKey){document.getElementById('logSensorFilter').value = sensorKey;updateCalibrationLog();document.getElementById('logTableBody').closest('.card').scrollIntoView({behavior:'smooth',block:'start'});showToast('Showing data points for selected tank');}window.viewTankPoints=viewTankPoints;async funct)HTML" R"HTML(ion resetCalibration(clientUid,sensorIndex){if(!confirm(`Reset calibration for sensor ${sensorIndex}? This will delete all calibration data for this sensor.`)){return;}try{const response = await fetch('/api/calibration',{method:'DELETE',headers:{'Content-Type':'application/json'},body:JSON.stringify({clientUid:clientUid,sensorIndex:sensorIndex})});if(!response.ok){const text = await response.text();throw new Error(text || 'Failed to reset calibration');}showToast('Calibration reset successfully');loadCalibrationData();}catch(err){console.error('Error resetting calibration:',err);showToast(err.message || 'Failed to reset calibration',true);}}window.resetCalibration=resetCalibration;})();})();
Comment thread .github/workflows/arduino-ci-112025.yml
Comment thread TankAlarm-112025-Server-BluesOpta/check_web_pages.py Outdated
Comment thread TankAlarm-112025-Server-BluesOpta/check_web_pages.py Outdated
@dorkmo
dorkmo force-pushed the website-handlers-and-clamps branch from 878684e to 45b0bc4 Compare September 14, 2026 19:02
@dorkmo dorkmo changed the title fix(web): export six dead inline handlers, clamp config inputs to firmware limits, add page checker to CI fix(web): bind six dead controls programmatically, restrict client UIDs, clamp config inputs, add page checker to CI Sep 14, 2026
dorkmo added a commit that referenced this pull request Sep 14, 2026
…lly in #315

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dorkmo
dorkmo force-pushed the website-handlers-and-clamps branch from 45b0bc4 to 780a473 Compare September 14, 2026 19:32
@dorkmo
dorkmo requested a balanced review from Copilot September 14, 2026 19:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Attribute escaping remains unsafe for persisted UIDs, and the checker silently accepts some unreachable handlers.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 2/3 changed files
  • Comments generated: 4
  • Review effort level: Balanced

@@ -2247,11 +2258,12 @@ funct)HTML" R"HTML(ion formatLevel(value,unit){if(typeof value !== 'number' || !
funct)HTML" R"HTML(ion populateSensorDropdowns(){const sensorSelect = document.getElementById('sensorSelect');const logSensorFilter = document.getElementById('logSensorFilter');sensorSelect.innerHTML = '<option value="">-- Select a sensor --</option>';logSensorFilter.innerHTML = '<option value="">All Sensors</option>';const uniqueTanks = new Map();sensors.forEach(t =>{const key = `${t.client}:${t.sensorIndex}`;if(!uniqueTanks.has(key)){uniqueTanks.set(key,{client:t.client,sensorIndex:t.sensorIndex,site:t.site,label:t.label || `Sensor ${t.sensorIndex}`,heightInches:t.heightInches || 0,currentValue:t.currentValue || 0,sensorMa:t.sensorMa || 0,lastUpdate:t.lastUpdate || 0,objectType:t.objectType||'tank',measurementUnit:t.measurementUnit||'inches',userNumber:t.userNumber||0});}});uniqueTanks.forEach((tank,key)=>{const option = document.createElement('option');option.value = key;const typeTag=tank.objectType==='tank'?'':'['+tank.objectType.toUpperCase()+'] ';option.textContent = `${typeTag}${tank.site} - ${tank.label}${tank.userNumber?' #'+tank.userNumber:''}`;sensorSelect.appendChild(option.cloneNode(true));logSensorFilter.appendChild(option);});}function updateLevelInput(){const sel=document.getElementById('sensorSelect').value;if(!sel){document.getElementById('levelInputTank').style.display='flex';document.getElementById('levelInputGeneric').style.display='none';return;}const [uid,tn]=sel.split(':');const tank=sensors.find(t=>t.client===uid&&t.sensorIndex===parseInt(tn));const ot=tank?tank.objectType:'tank';const mu=tank?tank.measurementUnit:'inches';if(ot==='tank'||mu==='inches'){document.getElementById('levelInputTank').style.display='flex';document.getElementById('levelInputGeneric').style.display='none';}else{document.getElementById('levelInputTank').style.display='none';document.getElementById('levelInputGeneric').style.display='block';document.getElementById('levelUnitLabel').textContent=mu.toUpperCase();}}document.getElementById('sensorSelect').addEventListener('change',updateLevelInput);
async funct)HTML" R"HTML(ion loadCalibrationData(){try{const response = await fetch('/api/calibration');if(!response.ok)throw new Error('Failed to load calibration data');const data = await response.json();calibrations = data.calibrations || [];calibrationLogs = data.logs || [];updateCalibrationStats();updateCalibrationTable();updateCalibrationLog();}catch(err){console.error('Error loading calibration data:',err);}}
funct)HTML" R"HTML(ion updateCalibrationStats(){const total = sensors.length > 0 ? new Set(sensors.map(t => `${t.client}:${t.sensorIndex}`)).size:0;const calibrated = calibrations.filter(c => c.hasLearnedCalibration).length;const learning = calibrations.filter(c => !c.hasLearnedCalibration && c.entryCount > 0).length;const uncalibrated = total - calibrated - learning;document.getElementById('statTotalTanks').textContent = total;document.getElementById('statCalibrated').textContent = calibrated;document.getElementById('statLearning').textContent = learning;document.getElementById('statUncalibrated').textContent = Math.max(0,uncalibrated);}
funct)HTML" R"HTML(ion updateCalibrationTable(){const tbody = document.getElementById('calibrationTableBody');tbody.innerHTML = '';if(calibrations.length === 0){tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;color:var(--muted);">No calibration data yet. Add readings to start learning.</td></tr>';return;}calibrations.forEach(cal =>{const tr = document.createElement('tr');const sensorInfo = sensors.find(t => t.client === cal.clientUid && t.sensorIndex === cal.sensorIndex);const sensorName = sensorInfo ? `${sensorInfo.label || 'Sensor ' + cal.sensorIndex}${sensorInfo.userNumber?' #'+sensorInfo.userNumber:''}`:`Sensor ${cal.sensorIndex}`;const site = sensorInfo ? sensorInfo.site:'--';let statusClass = 'uncalibrated';let statusText = 'Uncalibrated';let warnings = [];if(cal.hasLearnedCalibration){statusClass = 'calibrated';statusText = cal.hasTempCompensation ? 'Calibrated+Temp' : 'Calibrated';if(cal.rSquared < 0.95){warnings.push('Low R&sup2; fit (<95%)');}if(cal.entryCount === 2){warnings.push('Only 2 data points');}}else if(cal.entryCount > 0){statusClass = 'learning';statusText = 'Learning';if(cal.entryCount === 1){warnings.push('Need 1 more point');}}const sensorRange = cal.maxSensorMa - cal.minSensorMa;const levelRange = cal.maxLevelInches - cal.minLevelInches;if(cal.hasLearnedCalibration && sensorRange < 4){warnings.push('Narrow sensor range (<4mA)');}let driftText = '--';let driftClass = 'low';if(cal.hasLearnedCalibration && cal.originalMaxValue > 0){const originalSlope = cal.originalMaxValue / 16.0;const drift = Math.abs((cal.learnedSlope - originalSlope)/ originalSlope * 100);driftText = drift.toFixed(1)+ '%';if(drift > 10)driftClass = 'high';else if(drift > 5)driftClass = 'medium';}let tempCoefText = '--';if(cal.hasTempCompensation && cal.learnedTempCoef !== undefined){tempCoefText = cal.learnedTempCoef.toFixed(4) + ' '+getSensorUnit(cal.clientUid,cal.sensorIndex)+'/°F';}else if(cal.tempEntryCount > 0){tempCoefText = `(${cal.tempEntryCount} pts)`;}let rangeText = '--';if(cal.entryCount >= 1){rangeText = `${cal.minSensorMa.toFixed(1)}-${cal.maxSensorMa.toFixed(1)} mA`;}let warningHtml = '';if(warnings.length > 0){warningHtml = `<span class="quality-warning" title="${warnings.join(', ')}">&#x26A0;&#xFE0F;</span>`;}const sensorKey = `${cal.clientUid}:${cal.sensorIndex}`;tr.innerHTML = ` <td><a href="#" class="sensor-link" onclick="viewTankPoints('${sensorKey}');return false;" title="Click to view data points">${escapeHtml(sensorName)}</a>${warningHtml}</td><td>${escapeHtml(site)}</td><td><span class="calibration-status ${statusClass}">${statusText}</span></td><td title="Sensor range: ${rangeText}">${cal.entryCount}</td><td>${cal.hasLearnedCalibration ?(cal.rSquared * 100).toFixed(1)+ '%':'--'}</td><td>${cal.hasLearnedCalibration ? cal.learnedSlope.toFixed(3)+ ' '+getSensorUnit(cal.clientUid,cal.sensorIndex)+'/mA':'--'}</td><td title="Temperature coefficient (${getSensorUnit(cal.clientUid,cal.sensorIndex)} per °F deviation from 70°F)">${tempCoefText}</td><td><span class="drift-indicator ${driftClass}">${driftText}</span></td><td>${formatEpoch(cal.lastCalibrationEpoch)}</td><td><button class="btn-reset" onclick="resetCalibration('${cal.clientUid}',${cal.sensorIndex})" title="Reset calibration for this sensor">Reset</button></td> `;tbody.appendChild(tr);});}
document.getElementById('calibrationTableBody').addEventListener('click',e=>{const a=e.target.closest('a.sensor-link');if(a){e.preventDefault();viewTankPoints(a.dataset.key);return;}const b=e.target.closest('button.btn-reset');if(b)resetCalibration(b.dataset.uid,parseInt(b.dataset.idx,10));});
function updateCalibrationTable(){const tbody = document.getElementById('calibrationTableBody');tbody.innerHTML = '';if(calibrations.length === 0){tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;color:var(--muted);">No calibration data yet. Add readings to start learning.</td></tr>';return;}calibrations.forEach(cal =>{const tr = document.createElement('tr');const sensorInfo = sensors.find(t => t.client === cal.clientUid && t.sensorIndex === cal.sensorIndex);const sensorName = sensorInfo ? `${sensorInfo.label || 'Sensor ' + cal.sensorIndex}${sensorInfo.userNumber?' #'+sensorInfo.userNumber:''}`:`Sensor ${cal.sensorIndex}`;const site = sensorInfo ? sensorInfo.site:'--';let statusClass = 'uncalibrated';let statusText = 'Uncalibrated';let warnings = [];if(cal.hasLearnedCalibration){statusClass = 'calibrated';statusText = cal.hasTempCompensation ? 'Calibrated+Temp' : 'Calibrated';if(cal.rSquared < 0.95){warnings.push('Low R&sup2; fit (<95%)');}if(cal.entryCount === 2){warnings.push('Only 2 data points');}}else if(cal.entryCount > 0){statusClass = 'learning';statusText = 'Learning';if(cal.entryCount === 1){warnings.push('Need 1 more point');}}const sensorRange = cal.maxSensorMa - cal.minSensorMa;const levelRange = cal.maxLevelInches - cal.minLevelInches;if(cal.hasLearnedCalibration && sensorRange < 4){warnings.push('Narrow sensor range (<4mA)');}let driftText = '--';let driftClass = 'low';if(cal.hasLearnedCalibration && cal.originalMaxValue > 0){const originalSlope = cal.originalMaxValue / 16.0;const drift = Math.abs((cal.learnedSlope - originalSlope)/ originalSlope * 100);driftText = drift.toFixed(1)+ '%';if(drift > 10)driftClass = 'high';else if(drift > 5)driftClass = 'medium';}let tempCoefText = '--';if(cal.hasTempCompensation && cal.learnedTempCoef !== undefined){tempCoefText = cal.learnedTempCoef.toFixed(4) + ' '+getSensorUnit(cal.clientUid,cal.sensorIndex)+'/°F';}else if(cal.tempEntryCount > 0){tempCoefText = `(${cal.tempEntryCount} pts)`;}let rangeText = '--';if(cal.entryCount >= 1){rangeText = `${cal.minSensorMa.toFixed(1)}-${cal.maxSensorMa.toFixed(1)} mA`;}let warningHtml = '';if(warnings.length > 0){warningHtml = `<span class="quality-warning" title="${warnings.join(', ')}">&#x26A0;&#xFE0F;</span>`;}const sensorKey = `${cal.clientUid}:${cal.sensorIndex}`;tr.innerHTML = ` <td><a href="#" class="sensor-link" data-key="${escapeHtml(sensorKey)}" title="Click to view data points">${escapeHtml(sensorName)}</a>${warningHtml}</td><td>${escapeHtml(site)}</td><td><span class="calibration-status ${statusClass}">${statusText}</span></td><td title="Sensor range: ${rangeText}">${cal.entryCount}</td><td>${cal.hasLearnedCalibration ?(cal.rSquared * 100).toFixed(1)+ '%':'--'}</td><td>${cal.hasLearnedCalibration ? cal.learnedSlope.toFixed(3)+ ' '+getSensorUnit(cal.clientUid,cal.sensorIndex)+'/mA':'--'}</td><td title="Temperature coefficient (${getSensorUnit(cal.clientUid,cal.sensorIndex)} per °F deviation from 70°F)">${tempCoefText}</td><td><span class="drift-indicator ${driftClass}">${driftText}</span></td><td>${formatEpoch(cal.lastCalibrationEpoch)}</td><td><button class="btn-reset" data-uid="${escapeHtml(cal.clientUid)}" data-idx="${cal.sensorIndex}" title="Reset calibration for this sensor">Reset</button></td> `;tbody.appendChild(tr);});}
@@ -1482,6 +1482,17 @@ static bool isValidClientUid(const char *clientUid) {
return false;
}

// Only the characters Blues device UIDs use (dev: + IMEI digits, hex, ':' '_' '-' '.').
// Keeps every stored UID safe to embed in HTML attributes, JSON, log lines and file names.
for (const char *p = clientUid + 4; *p; ++p) {
Comment thread TankAlarm-112025-Server-BluesOpta/check_web_pages.py Outdated
Comment thread TankAlarm-112025-Server-BluesOpta/check_web_pages.py Outdated
dorkmo added a commit that referenced this pull request Sep 14, 2026
…e, persisted UID validation

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dorkmo
dorkmo force-pushed the website-handlers-and-clamps branch from 780a473 to b62a0f0 Compare September 14, 2026 19:52
@dorkmo
dorkmo requested a balanced review from Copilot September 14, 2026 19:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Legacy UID paths still permit stored script injection, and the new checker has a control-flow false negative.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 2/3 changed files
  • Comments generated: 3
  • Review effort level: Balanced

@@ -2131,7 +2148,7 @@ window.toggleAlarmSection=function(id){const card=document.getElementById(`senso
window.removeAlarmSection=function(id){const card=document.getElementById(`sensor-${id}`);const alarmSection=card.querySelector('.alarm-section');const addAlarmBtn=card.querySelector('.add-alarm-btn');const addRelayBtn=card.querySelector('.add-relay-btn');const addMsgBtn=card.querySelector('.add-msg-btn');const relaySection=card.querySelector('.relay-section');const msgSection=card.querySelector('.msg-section');alarmSection.classList.remove('visible');addAlarmBtn.classList.remove('hidden');addRelayBtn.classList.add('hidden');addMsgBtn.classList.add('hidden');relaySection.classList.remove('visible');msgSection.classList.remove('visible');card.querySelector('.high-alarm').value='100';card.querySelector('.low-alarm').value='20';card.querySelector('.high-alarm-enabled').checked=true;card.querySelector('.low-alarm-enabled').checked=true;card.querySelector('.relay-target').value='';card.querySelector('.relay-trigger').value='any';card.querySelector('.relay-mode').value='momentary';['relay-1','relay-2','relay-3','relay-4'].forEach(cls=>{card.querySelector('.'+cls).checked=false;});};
window.toggleRelaySection=function(id){const card=document.getElementById(`sensor-${id}`);const relaySection=card.querySelector('.relay-section');const addBtn=card.querySelector('.add-relay-btn');relaySection.classList.add('visible');addBtn.classList.add('hidden');};
window.removeRelaySection=function(id){const card=document.getElementById(`sensor-${id}`);const relaySection=card.querySelector('.relay-section');const addBtn=card.querySelector('.add-relay-btn');relaySection.classList.remove('visible');addBtn.classList.remove('hidden');card.querySelector('.relay-target').value='';card.querySelector('.relay-trigger').value='any';card.querySelector('.relay-mode').value='momentary';['relay-1','relay-2','relay-3','relay-4'].forEach(cls=>{card.querySelector('.'+cls).checked=false;});['relay-duration-1','relay-duration-2','relay-duration-3','relay-duration-4'].forEach(cls=>{card.querySelector('.'+cls).value='0';});card.querySelector('.relay-max-on').value='0';card.querySelector('.relay-durations-section').style.display='block';card.querySelector('.relay-max-on-section').style.display='none';};
function escapeHtml(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
function escapeHtml(s){if(!s)return'';return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#039;');}
@@ -8557,6 +8578,7 @@ static bool loadFtpArchiveCached(uint16_t year, uint8_t month) {

auto &cached = gFtpArchiveCache.sensors[gFtpArchiveCache.sensorCount];
strlcpy(cached.clientUid, sensorObj["clientUid"] | "", sizeof(cached.clientUid));
if (cached.clientUid[0] != '\0' && !isValidClientUid(cached.clientUid)) continue; // persisted before UID validation existed
Comment thread TankAlarm-112025-Server-BluesOpta/check_web_pages.py Outdated
@dorkmo
dorkmo force-pushed the website-handlers-and-clamps branch from b62a0f0 to 731641b Compare September 14, 2026 20:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Number parsing misreads valid exponent inputs, and the checker has false-negative paths for unreachable handlers.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

TankAlarm-112025-Server-BluesOpta/check_web_pages.py:451

  • _rhs_kind treats a call expression as a bare identifier because this match stops after the callee name. Thus (async()=>{ function make(){}; window.bad=make(); })() is reported as exporting bad, even though make() may return undefined; an inline bad() then fails at runtime while this checker passes. Reject identifiers followed by a call/member-expression token (and add this case to selftest) unless the resulting value is provably callable.
    m = re.match(r'\s*(async\s+)?([A-Za-z_$][\w$]*)\s*(=>)?', js[i:i + 80])
    if m and m.group(2) not in ('undefined', 'null', 'true', 'false', 'new', 'void', 'typeof'):
        return '' if m.group(3) else m.group(2)
  • Files reviewed: 2/3 changed files
  • Comments generated: 2
  • Review effort level: Balanced

@@ -2169,9 +2204,9 @@ window.removeInput=function(id){const card=document.getElementById(`input-${id}`
const addInputBtn=document.getElementById('addInputBtn');if(addInputBtn)addInputBtn.addEventListener('click',addInput);
/* CONFIG COLLECTION */
function sensorKeyFromValue(value){switch(value){case 0:return 'digital';case 2:return 'current';case 3:return 'rpm';default:return 'analog';}}
function collectConfig(){if(!validatePowerCombination()){showToast('Invalid power/battery combination — see warning above',true);return null;}const sMinutes=Math.max(1,Math.min(1440,parseInt(document.getElementById('sampleMinutes').value,10)||30));const dailyOffsetMinutes=parseInt(document.getElementById('dailyOffsetSelect').value,10)||0;const _cU=((emailUtcMinutes()-dailyOffsetMinutes)%1440+1440)%1440;const reportHour=Math.floor(_cU/60);const reportMinute=_cU%60;const psRaw=document.getElementById('powerSource').value||'grid';const btStr=document.getElementById('batteryType').value||'agm';const bvNominal=parseInt(document.getElementById('batteryVoltage').value,10)||12;const vmon=document.getElementById('voltageMonitor').value||'none';const hasVin=vmon==='vin-divider';const hasRs485=vmon==='rs485';const isSolarOnly=(btStr==='none'&&psRaw.startsWith('solar'));/* battery type enum value matching TankAlarm_Battery.h (chemistry only) */const btEnumMap={'none':0,'agm':1,'flooded':2,'gel':3,'sla':4,'lifepo4':5,'li_ion':6,'lipo':7,'custom':8};const btEnum=btEnumMap[btStr]!==undefined?btEnumMap[btStr]:1;const cfg={productUid:document.getElementById('productUid').value.trim(),deviceUid:(document.getElementById('clientUid').value||'').trim(),site:document.getElementById('siteName').value.trim(),deviceLabel:document.getElementById('deviceLabel').value.trim()||'Unconfigured Client',clientFleet:(document.getElementById('clientFleet').value||'').trim(),serverFleet:document.getElementById('serverFleet').value.trim()||'tankalarm-server',sampleSeconds:sMinutes*60,reportHour:reportHour,reportMinute:reportMinute,dailyEmail:'',dailyReportOffsetMinutes:dailyOffsetMinutes,powerSupply:psRaw,solarPowered:psRaw.startsWith('solar'),mpptEnabled:psRaw.includes('mppt'),solarCharger:{enabled:hasRs485},voltageMonitor:vmon,batteryConfig:{enabled:btStr!=='none',batteryType:btEnum,batteryTypeName:btStr,nominalVoltage:(btStr==='none'||btStr==='lipo')?0:bvNominal},vinMonitor:{enabled:hasVin,pin:hasVin?parseInt(document.getElementById('vinPin').value)||0:0,r1Kohm:hasVin?parseFloat(document.getElementById('vinR1').value)||22:22,r2Kohm:hasVin?parseFloat(document.getElementById('vinR2').value)||47:47},solarOnlyConfig:{enabled:isSolarOnly,startupDebounceVoltage:(isSolarOnly&&hasVin)?parseFloat(document.getElementById('solarOnlyDebounceV').value)||10:10,startupDebounceSec:(isSolarOnly&&hasVin)?parseInt(document.getElementById('solarOnlyDebounceSec').value)||30:30,startupWarmupSec:(isSolarOnly&&!hasVin)?parseInt(document.getElementById('solarOnlyWarmupSec').value)||60:60,sensorGateVoltage:(isSolarOnly&&hasVin)?parseFloat(document.getElementById('solarOnlySensorGateV').value)||11:11,sunsetVoltage:(isSolarOnly&&hasVin)?parseFloat(document.getElementById('solarOnlySunsetV').value)||10:10,sunsetConfirmSec:isSolarOnly?parseInt(document.getElementById('solarOnlySunsetSec').value)||120:120,opportunisticReportHours:isSolarOnly?parseInt(document.getElementById('solarOnlyReportHours').value)||20:20,batteryFailureFallback:(!isSolarOnly&&psRaw.startsWith('solar')&&btStr!=='none')?!!document.getElementById('solarOnlyBatFail').checked:false,batteryFailureThreshold:parseInt(document.getElementById('solarOnlyBatFailCount').value)||10},sensors:[],clearButtonPin:-1,clearButtonActiveHigh:false};
function collectConfig(){if(!validatePowerCombination()){showToast('Invalid power/battery combination — see warning above',true);return null;}const sMinutes=Math.max(1,Math.min(1092,parseInt(document.getElementById('sampleMinutes').value,10)||30));const dailyOffsetMinutes=parseInt(document.getElementById('dailyOffsetSelect').value,10)||0;const _cU=((emailUtcMinutes()-dailyOffsetMinutes)%1440+1440)%1440;const reportHour=Math.floor(_cU/60);const reportMinute=_cU%60;const psRaw=document.getElementById('powerSource').value||'grid';const btStr=document.getElementById('batteryType').value||'agm';const bvNominal=parseInt(document.getElementById('batteryVoltage').value,10)||12;const vmon=document.getElementById('voltageMonitor').value||'none';const hasVin=vmon==='vin-divider';const hasRs485=vmon==='rs485';const isSolarOnly=(btStr==='none'&&psRaw.startsWith('solar'));/* battery type enum value matching TankAlarm_Battery.h (chemistry only) */const btEnumMap={'none':0,'agm':1,'flooded':2,'gel':3,'sla':4,'lifepo4':5,'li_ion':6,'lipo':7,'custom':8};const btEnum=btEnumMap[btStr]!==undefined?btEnumMap[btStr]:1;const cfg={productUid:document.getElementById('productUid').value.trim(),deviceUid:(document.getElementById('clientUid').value||'').trim(),site:document.getElementById('siteName').value.trim(),deviceLabel:document.getElementById('deviceLabel').value.trim()||'Unconfigured Client',clientFleet:(document.getElementById('clientFleet').value||'').trim(),serverFleet:document.getElementById('serverFleet').value.trim()||'tankalarm-server',sampleSeconds:sMinutes*60,reportHour:reportHour,reportMinute:reportMinute,dailyEmail:'',dailyReportOffsetMinutes:dailyOffsetMinutes,powerSupply:psRaw,solarPowered:psRaw.startsWith('solar'),mpptEnabled:psRaw.includes('mppt'),solarCharger:{enabled:hasRs485},voltageMonitor:vmon,batteryConfig:{enabled:btStr!=='none',batteryType:btEnum,batteryTypeName:btStr,nominalVoltage:(btStr==='none'||btStr==='lipo')?0:bvNominal},vinMonitor:{enabled:hasVin,pin:hasVin?parseInt(document.getElementById('vinPin').value)||0:0,r1Kohm:hasVin?parseFloat(document.getElementById('vinR1').value)||22:22,r2Kohm:hasVin?parseFloat(document.getElementById('vinR2').value)||47:47},solarOnlyConfig:{enabled:isSolarOnly,startupDebounceVoltage:(isSolarOnly&&hasVin)?parseFloat(document.getElementById('solarOnlyDebounceV').value)||10:10,startupDebounceSec:(isSolarOnly&&hasVin)?parseInt(document.getElementById('solarOnlyDebounceSec').value)||30:30,startupWarmupSec:(isSolarOnly&&!hasVin)?parseInt(document.getElementById('solarOnlyWarmupSec').value)||60:60,sensorGateVoltage:(isSolarOnly&&hasVin)?parseFloat(document.getElementById('solarOnlySensorGateV').value)||11:11,sunsetVoltage:(isSolarOnly&&hasVin)?parseFloat(document.getElementById('solarOnlySunsetV').value)||10:10,sunsetConfirmSec:isSolarOnly?parseInt(document.getElementById('solarOnlySunsetSec').value)||120:120,opportunisticReportHours:isSolarOnly?parseInt(document.getElementById('solarOnlyReportHours').value)||20:20,batteryFailureFallback:(!isSolarOnly&&psRaw.startsWith('solar')&&btStr!=='none')?!!document.getElementById('solarOnlyBatFail').checked:false,batteryFailureThreshold:parseInt(document.getElementById('solarOnlyBatFailCount').value)||10},sensors:[],clearButtonPin:-1,clearButtonActiveHigh:false};
Comment thread TankAlarm-112025-Server-BluesOpta/check_web_pages.py Outdated
@dorkmo
dorkmo force-pushed the website-handlers-and-clamps branch from bb1ce10 to 55905c0 Compare September 14, 2026 21:22
@dorkmo
dorkmo requested a balanced review from Copilot September 14, 2026 21:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The configuration API can falsely accept missing UIDs, and the new checker has paths that silently accept unreachable handlers.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 2/3 changed files
  • Comments generated: 3
  • Review effort level: Balanced

@@ -10792,6 +10833,10 @@ static void handleConfigPost(EthernetClient &client, const String &body) {
if (doc["client"] && doc["config"]) {
Comment thread TankAlarm-112025-Server-BluesOpta/check_web_pages.py Outdated
Comment thread TankAlarm-112025-Server-BluesOpta/check_web_pages.py
@dorkmo
dorkmo force-pushed the website-handlers-and-clamps branch from 55905c0 to 34b508a Compare September 14, 2026 21:39
@dorkmo
dorkmo requested a balanced review from Copilot September 14, 2026 21:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

UID validation remains bypassable, missing-client requests can silently succeed, and the checker has a confirmed false-negative case.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 2/3 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment on lines +1511 to +1515
for (const char *p = clientUid + 4; *p; ++p) {
if (!(isalnum((unsigned char)*p) || *p == ':' || *p == '_' || *p == '-' || *p == '.')) {
Serial.print(F("WARNING: Rejected client UID with unexpected character: "));
serialPrintUntrusted(clientUid);
addServerSerialLog("Rejected invalid client UID (unexpected character)", "warn", "telemetry");
Comment thread TankAlarm-112025-Server-BluesOpta/check_web_pages.py
@dorkmo
dorkmo force-pushed the website-handlers-and-clamps branch from 34b508a to c596e8a Compare September 14, 2026 21:55
@dorkmo
dorkmo requested a balanced review from Copilot September 14, 2026 21:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Missing UID handling remains bypassable, calibration writes still accept invalid UIDs, and the checker allows a non-callable browser global.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 2/3 changed files
  • Comments generated: 2
  • Review effort level: Balanced

@@ -19435,6 +19508,7 @@ static void handleCalibrationGet(EthernetClient &client) {
}
}

if (!isValidClientUid(uid.c_str())) continue; // entries logged before UID validation existed
Comment thread TankAlarm-112025-Server-BluesOpta/check_web_pages.py Outdated
@dorkmo
dorkmo force-pushed the website-handlers-and-clamps branch from c596e8a to 18b5c43 Compare September 14, 2026 22:15
@dorkmo
dorkmo requested a balanced review from Copilot September 14, 2026 22:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Missing-UID requests still return 200, some UID ingestion paths remain unvalidated, and the checker has false-positive and false-negative cases.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 2/3 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment on lines +1503 to +1505
// Only the characters Blues device UIDs use (dev: + IMEI digits, hex, ':' '_' '-' '.'), and
// at least one of them: keeps every stored UID safe to embed in HTML attributes, JSON, log
// lines and file names, and rejects the bare prefix.
Comment thread TankAlarm-112025-Server-BluesOpta/check_web_pages.py Outdated
Comment thread TankAlarm-112025-Server-BluesOpta/check_web_pages.py
dorkmo and others added 3 commits September 14, 2026 17:27
…Ds, clamp config inputs, add page checker to CI

Six page controls threw ReferenceError on click because their handlers were
declared inside the page's async IIFE and never reachable from the inline
onclick/onchange attributes that called them (S-W02):

- /site-config: Expect Update, Remove Client (and Edit Config, click-to-copy UID)
- /calibration: Reset (per sensor), sensor-name link (view data points)
- /transmission-log: Cancel (pending config)
- /historical: custom date range start/end

Those attributes also spliced client UIDs into JavaScript source, so a UID
containing a quote could run code once the handlers worked. The controls now
carry their UID/key in HTML-escaped data attributes and are bound by a
delegated listener inside the page IIFE; no inline handler and no window
export is needed for them.

isValidClientUid() additionally restricts UIDs to letters, digits, ':', '_',
'-' and '.', so every UID the server stores (telemetry, alarms, daily) is
safe to embed in attributes, JSON, log lines and file names; handleConfigPost
applies the same check to operator-submitted UIDs before storing a snapshot
(and rejects a config without a client UID instead of answering 200), and
findOrCreateClientMetadata() refuses to create metadata for a malformed id,
which covers OTA acknowledgements, location replies and /api/ota/expect,
and the calibration POST/DELETE handlers, the client serial-log buffer and
the unload handler validate the id before any lookup, write or notification.

Config generator inputs are clamped to what the client firmware can store:
- Sample Minutes max 1440 -> 1092 (sampleSeconds is a uint16_t; 1440 min
  = 86400 s does not fit and the client fell back to the default interval)
- Momentary relay durations max 86400 -> 65535 and clamped in collectConfig
  (relayMomentarySeconds[] is a uint16_t; larger values became 0)

New TankAlarm-112025-Server-BluesOpta/check_web_pages.py extracts every
PROGMEM page from the server and viewer sketches, runs `node --check` on
each script block, and asserts that every function called from an inline
on<event> attribute (each call in the value, not only the first) resolves
to a top-level function or a window.* assignment that executes on load
(IIFE or load/DOMContentLoaded listener; not a function declaration,
callback or expression-bodied arrow, and only when the assigned value is a
function expression, an arrow, or a declared function). The tokeniser handles strings,
templates, comments and regex literals after punctuation or keywords. It
fails closed when node, a sketch, or any page is missing and carries a
--selftest of 23 synthetic cases. A `check-web-pages` CI job runs the
self-test and the scan on every push and pull request against any base
branch; build-firmware depends on it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ally too

The dashboard's Update, Snooze Reminders, Clear Relay and Remove Client
buttons and the client console's Edit Config / Edit Configuration and
Approve Deletion buttons still spliced client UIDs into inline onclick
JavaScript (the same pattern the first commit removed from the six dead
controls; encodeURIComponent() does not encode a single quote either).
They now carry the UID and, where needed, the sensor index and action in
HTML-escaped data attributes and are handled by one delegated listener per
page. The client console's UID readout is HTML-escaped as well. No page
renders a client UID into JavaScript source any more.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…IDs at boot

Six pages implemented escapeHtml() by serialising a text node, which
escapes & < > but not quotes, so a stored UID containing a double quote
could break out of an attribute value. Every page now uses the same
five-character escaper (& < > " ') the dashboard already had; text output
is unchanged.

Records persisted before isValidClientUid() gained its character set
(calibrations, calibration summaries, hot-tier history and FTP cache
summaries, client config snapshots, client metadata) are validated when
they are loaded at boot and skipped with a serial warning if their UID is
not a valid device UID, so the pages never see one. The complete stored
value is validated before it is copied into the fixed-size field, so an
overlong id cannot pass as its truncated prefix, calibration log entries
with an invalid id are omitted from /api/calibration, and the persisted
sensor registry drops (and re-saves without) records whose id fails the
check. Rejected ids are logged through a sanitising printer so a stray
control byte cannot forge log lines.

The config generator's cloud client picker, the last control that put a
UID into an inline handler, carries it in a data attribute with a
delegated listener like the others. isValidClientUid() also rejects the
bare "dev:" prefix, and empty persisted ids are skipped rather than
counted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dorkmo
dorkmo force-pushed the website-handlers-and-clamps branch from 18b5c43 to 9c0368b Compare September 14, 2026 22:34
@dorkmo
dorkmo requested a balanced review from Copilot September 14, 2026 22:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Missing config UIDs can still return 200, and the page checker accepts exports unreachable after an enclosing return.

Review details

Suppressed comments (1)

TankAlarm-112025-Server-BluesOpta/check_web_pages.py:477

  • Unreachability is checked only on the innermost frame. For example, (async()=>{ function dead(){} return; { window.dead=dead; } })() is accepted even though dead is never exported, allowing the dead-handler regression this checker is meant to block. Propagate unreachability from every enclosing frame (and add this case to the self-test).
                    unreachable = stack[-1][4] if stack else top_unreachable
  • Files reviewed: 2/3 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@dorkmo
dorkmo deleted the branch master September 15, 2026 14:25
@dorkmo dorkmo closed this Sep 15, 2026
@dorkmo dorkmo reopened this Sep 15, 2026
@dorkmo
dorkmo changed the base branch from website-style-consistency to master September 15, 2026 14:30
@dorkmo
dorkmo merged commit 2aaecf1 into master Sep 15, 2026
9 checks passed
@dorkmo
dorkmo deleted the website-handlers-and-clamps branch September 15, 2026 14:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants