mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
fix(web-ui): MQTT password without TLS, Overview poll that never stopped, brightness slider error, token form left dirty (#745)
* fix(web-ui): let the MQTT bridge form save a password without TLS
PUT /api/v3/integrations/mqtt-bridge/config refuses a stored password
while mqtt_tls is off unless allow_insecure_mqtt is set (the CWE-319
guard in api_v3/misc.py). The Tools tab form neither rendered a control
for that flag nor sent it, so a password-protected broker on a LAN
without TLS could never be saved from the UI, and once such a password
was in bridge_config.json every later save from the form was refused.
The form now shows "Allow without TLS (trusted network)" while "Use
TLS" is unchecked, prefilled from the GET's config.allow_insecure_mqtt,
and mqttBody() sends its state as allow_insecure_mqtt. The box is off
until the user ticks it, so the server's guard still refuses a
cleartext password by default.
Tests: the Tools DOM suite checks the control, its show/hide with the
TLS box, the prefill and the value saved; a Flask test pins that the
GET reports the opt-in (false until saved on).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web-ui): stop the Overview reconciliation poll from running forever
The reconciliation banner script in partials/overview.html re-asked
/api/v3/plugins/reconciliation-status every 2 s until the answer said
done, with no limit. The route answers done: false whenever
ledmatrix_reconciliation.json is missing or unreadable, which happens
when _run_startup_reconciliation raises before writing it or when /tmp
is cleaned under a long-running web service (reconciliation runs once
per process). The browser then sent that request every 2 s for as long
as the page stayed open, on every tab, since the poll was never tied to
the Overview being visible.
The poll now gives up after 30 tries (a minute) and runs only while the
Overview is the active, visible tab, registered with LEDVisibility under
its own key like the other partials' pollers. Dismissing the banner
ends it too.
Test: test/js/unit/test_overview_reconciliation_poll.js runs the shipped
script in a vm with fake timers and fetch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web-ui): drop the Display tab's lookup of a removed brightness label
The brightness slider's input handler in partials/display.html set the
text of both #brightness-value and #brightness-display. #387
(978a03b42) removed the "LED brightness: N%" line that carried
#brightness-display, so getElementById returned null and every step of
the slider threw "Cannot set properties of null" into the console. The
visible label still updated, because it is written first.
The dead lookup is removed.
Test: test/js/unit/test_display_partial_ids.js checks every literal
getElementById() in the partial's inline scripts against the ids its
markup renders, and runs the shipped script in a vm to move the slider.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web-ui): a created API token leaves the General tab's form clean
app.js marks a form data-dirty on any input inside it and removes the
mark only after a successful htmx request; its beforeunload handler
asks "Leave site?" while a visible form is still dirty. The API token
form in partials/general.html posts through window.webLogin.createToken
with fetch, so the mark survived the token being created and a reload
of the page with the General tab open prompted about a change that had
already been saved.
createToken now removes data-dirty after a successful create, next to
the form.reset() it already did. A refused request keeps the mark.
Test: test/js/unit/test_general_web_login_token.js runs the shipped
script in a vm with a fake fetch and DOM.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(js): match <script> tags the way CodeQL's tag-filter rule expects
The three new suites pull the inline scripts out of their partials with
/<script>([\s\S]*?)<\/script>/g. CodeQL flags that shape as a bad HTML
filtering regexp (js/bad-tag-filter: misses upper case and tags with
attributes or whitespace), four high alerts that blocked the PR. These are
our own templates read by tests, not user input, but the stricter pattern
costs nothing: /<script\b[^>]*>(...)<\/script[^>]*>/gi, as
test_html_escaping.js already uses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(js): slice the Display partial's markup around its scripts
CodeQL read the script-stripping replace() as an incomplete HTML sanitizer
(js/incomplete-multi-character-sanitization). The test only reads our own
template, but slicing between the matched blocks gives the same markup
without the pattern.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -884,7 +884,6 @@ With this off a live game takes over the whole display with the full-screen scor
|
||||
// Update brightness display
|
||||
document.getElementById('brightness').addEventListener('input', function() {
|
||||
document.getElementById('brightness-value').textContent = this.value;
|
||||
document.getElementById('brightness-display').textContent = this.value;
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -404,6 +404,10 @@
|
||||
document.getElementById('web-login-new-token-value').textContent = res.d.data.token;
|
||||
document.getElementById('web-login-new-token').classList.remove('hidden');
|
||||
form.reset();
|
||||
// app.js marks a form dirty on input and clears the mark only
|
||||
// after an htmx save; this one posts with fetch, so clear it
|
||||
// here or a reload asks "Leave site?" about a saved token.
|
||||
form.removeAttribute('data-dirty');
|
||||
notify(res.d.message || 'Token created', 'success');
|
||||
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
|
||||
},
|
||||
|
||||
@@ -55,19 +55,40 @@
|
||||
<script>
|
||||
(function () {
|
||||
var DISMISS_KEY = 'ledmatrix-recon-dismissed';
|
||||
// Startup reconciliation is done within seconds of the web service
|
||||
// starting. The route also answers done: false when its status file is
|
||||
// missing (reconciliation raised before writing it, or /tmp was cleaned
|
||||
// under a long-running service), which used to keep this polling every
|
||||
// 2 s for as long as the page was open, on every tab. So: give up after
|
||||
// a minute, and poll only while the Overview is on screen.
|
||||
var POLL_MS = 2000;
|
||||
var MAX_POLLS = 30;
|
||||
var _recon_timer = null;
|
||||
var _polls = 0;
|
||||
var _finished = false; // done, given up, or dismissed
|
||||
var _active = false;
|
||||
var _inFlight = false;
|
||||
|
||||
function checkReconciliation() {
|
||||
_recon_timer = null;
|
||||
_inFlight = true;
|
||||
fetch('/api/v3/plugins/reconciliation-status')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (resp) {
|
||||
_inFlight = false;
|
||||
if (_finished) return;
|
||||
var d = resp.data || {};
|
||||
if (!d.done) {
|
||||
// Reconciliation still running — poll again shortly
|
||||
_recon_timer = setTimeout(checkReconciliation, 2000);
|
||||
// Reconciliation still running (or it never wrote its
|
||||
// status): ask again shortly, a bounded number of times.
|
||||
if (++_polls >= MAX_POLLS) {
|
||||
_finished = true;
|
||||
return;
|
||||
}
|
||||
if (_active) _recon_timer = setTimeout(checkReconciliation, POLL_MS);
|
||||
return;
|
||||
}
|
||||
_recon_timer = null;
|
||||
_finished = true;
|
||||
if (!d.unresolved || d.unresolved.length === 0) return;
|
||||
var key = d.unresolved.map(function (i) { return i.plugin_id; }).sort().join(',');
|
||||
if (sessionStorage.getItem(DISMISS_KEY) === key) return;
|
||||
@@ -102,13 +123,33 @@
|
||||
banner.dataset.dismissKey = key;
|
||||
banner.style.setProperty('display', 'flex', 'important');
|
||||
})
|
||||
.catch(function () {});
|
||||
.catch(function () { _inFlight = false; });
|
||||
}
|
||||
|
||||
function startReconciliationPoll() {
|
||||
_active = true;
|
||||
if (!_finished && _recon_timer === null && !_inFlight) checkReconciliation();
|
||||
}
|
||||
function stopReconciliationPoll() {
|
||||
_active = false;
|
||||
if (_recon_timer !== null) {
|
||||
clearTimeout(_recon_timer);
|
||||
_recon_timer = null;
|
||||
}
|
||||
}
|
||||
// Keyed apart from any other Overview registration, which a shared key
|
||||
// would replace.
|
||||
if (window.LEDVisibility) {
|
||||
window.LEDVisibility.onActive('overview', startReconciliationPoll,
|
||||
stopReconciliationPoll, 'overview-reconciliation');
|
||||
} else {
|
||||
startReconciliationPoll();
|
||||
}
|
||||
checkReconciliation();
|
||||
|
||||
window.dismissReconciliationBanner = function () {
|
||||
var banner = document.getElementById('reconciliation-banner');
|
||||
banner.style.setProperty('display', 'none', 'important');
|
||||
_finished = true;
|
||||
if (_recon_timer !== null) {
|
||||
clearTimeout(_recon_timer);
|
||||
_recon_timer = null;
|
||||
|
||||
@@ -1093,6 +1093,12 @@
|
||||
Use TLS
|
||||
<span class="text-xs text-gray-500">(a password without TLS crosses the network in the clear)</span>
|
||||
</label>
|
||||
<label id="mqtt-allow-insecure-row" class="${c.mqtt_tls ? 'hidden' : 'flex'} items-center gap-2 text-sm text-gray-700">
|
||||
<input id="mqtt-allow-insecure" type="checkbox" ${c.allow_insecure_mqtt ? 'checked' : ''}
|
||||
class="rounded border-gray-300">
|
||||
Allow without TLS (trusted network)
|
||||
<span class="text-xs text-gray-500">(needed to save a password while TLS is off)</span>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 pt-2">
|
||||
<p class="text-xs text-gray-500">
|
||||
@@ -1132,6 +1138,15 @@
|
||||
if (clearBtn) clearBtn.addEventListener('click', () => clearMqttPassword());
|
||||
const clearTokenBtn = document.getElementById('mqtt-clear-api-token');
|
||||
if (clearTokenBtn) clearTokenBtn.addEventListener('click', () => clearMqttApiToken());
|
||||
// The cleartext opt-in only means something while TLS is off.
|
||||
const tlsBox = document.getElementById('mqtt-tls');
|
||||
const allowRow = document.getElementById('mqtt-allow-insecure-row');
|
||||
if (tlsBox && allowRow) {
|
||||
tlsBox.addEventListener('change', () => {
|
||||
allowRow.classList.toggle('hidden', tlsBox.checked);
|
||||
allowRow.classList.toggle('flex', !tlsBox.checked);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.loadMqttBridge = function() {
|
||||
@@ -1160,6 +1175,9 @@
|
||||
on_demand_duration: val('mqtt-duration') === '' ? null : val('mqtt-duration'),
|
||||
log_level: val('mqtt-log-level'),
|
||||
mqtt_tls: !!(document.getElementById('mqtt-tls') || {}).checked,
|
||||
// The server refuses a password with TLS off unless this is set
|
||||
// (CWE-319); it is off until the user ticks it.
|
||||
allow_insecure_mqtt: !!(document.getElementById('mqtt-allow-insecure') || {}).checked,
|
||||
};
|
||||
// Only send a password when one was typed; blank means "leave it alone".
|
||||
const pw = val('mqtt-password');
|
||||
|
||||
Reference in New Issue
Block a user