diff --git a/CHANGELOG.md b/CHANGELOG.md index 2acd739a..d581dd30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,16 @@ accepts both, but the store flags the old spelling as deprecated - `/plugins/store/refresh` no longer claims a commit-metadata refresh it doesn't do. - The plugin-config list repair code is in one place, `src/web_interface/config_arrays.py`. +- Web UI: + - Cache tab errors no longer show up in the Logs tab. + - A tab that fails to load shows "Try again" instead of a skeleton that never goes away. + - Plugin Store search and registry errors appear as a notification, and the Plugin Manager stays on screen. + - The image schedule button works on uploaded images, and the editor stays open while you edit. + - A failed plugin toggle moves the switch back. + - Each save shows one notification; a failed Durations save says it failed. + - Stats the server can't read show `--`. + - New `window.LEDEscape` (`html`, `attr`, `jsStringAttr`) replaces about 30 copied escapers. `window.escapeHtml` and `window.escapeAttribute` remain as aliases for plugin pages. + - The web service (`ledmatrix-web`) logs through `src.logging_config` like the display service, so `journalctl -p err -u ledmatrix-web` works. Successful GET/HEAD/OPTIONS requests (the UI's polling) are logged at DEBUG instead of diff --git a/test/js/dom/test_installed_dom.js b/test/js/dom/test_installed_dom.js index 5fbee8c6..aa5f70ab 100644 --- a/test/js/dom/test_installed_dom.js +++ b/test/js/dom/test_installed_dom.js @@ -43,12 +43,7 @@ function get(path) { window.debugLog = () => {}; window.PLUGIN_DEBUG = false; window.installedPlugins = installed; - window.escapeHtml = function (text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - }; + require('../led_escape').install(window); // Load the helper as a real ', 'a'b']) { + const out = LEDEscape.jsStringAttr(v); + ok(`${JSON.stringify(v)}: no raw quote or bracket`, !/["'<>]/.test(out), out); + // eslint-disable-next-line no-eval + ok(`${JSON.stringify(v)}: decodes back to the same string`, eval(decode(out)) === v, out); + } + ok('null and undefined become the empty string', + LEDEscape.html(null) === '' && LEDEscape.html(undefined) === '' && LEDEscape.jsStringAttr(null) === '""'); + ok('numbers are kept', LEDEscape.html(0) === '0', LEDEscape.html(0)); +} + +console.log('\n4c. no hand-rolled escaper outside app-early.js'); +{ + const skip = new Set(['static/v3/js/app-early.js', + // documentation example, kept self-contained on purpose + 'static/v3/js/widgets/example-color-picker.js']); + const found = []; + const walk = dir => fs.readdirSync(dir, { withFileTypes: true }).forEach(e => { + const p = path.join(dir, e.name); + const rel = path.relative(ROOT, p).split(path.sep).join('/'); + if (e.isDirectory()) { if (e.name !== 'vendor') walk(p); return; } + if (!/\.(js|html)$/.test(e.name) || /\.min\.js$/.test(e.name) || skip.has(rel)) return; + // Writing the entity for a quote is what an escaper does; nothing else in + // the UI needs to. + let text = fs.readFileSync(p, 'utf8'); + // In templates only the inline scripts count; Jinja's own |replace("'", "'") + // escaping of server-rendered values is not a JS escaper. + if (e.name.endsWith('.html')) text = (text.match(/]*>[\s\S]*?<\/script\s*>/gi) || []).join('\n'); + if (/['"`]"['"`]|['"`]'['"`]/.test(text)) found.push(rel); + }); + walk(path.join(ROOT, 'static')); + walk(path.join(ROOT, 'templates')); + ok('every escaper is window.LEDEscape', found.length === 0, found); + // Plugin pages may call these globals; they stay, as aliases. + const early = fs.readFileSync(path.join(ROOT, 'static/v3/js/app-early.js'), 'utf8'); + ok('window.escapeHtml is kept as an alias of LEDEscape.html', + early.includes('window.escapeHtml = window.LEDEscape.html;')); + ok('window.escapeAttribute is kept as an alias of LEDEscape.attr', + early.includes('window.escapeAttribute = window.LEDEscape.attr;')); +} + // ── url-input scheme handling (js/xss-through-dom) ───────────────────────── console.log('\n5. url-input never treats a scriptable scheme as a valid URL'); { diff --git a/test/js/unit/test_inline_handler_escaping.js b/test/js/unit/test_inline_handler_escaping.js index 984d4299..e32d6d8c 100644 --- a/test/js/unit/test_inline_handler_escaping.js +++ b/test/js/unit/test_inline_handler_escaping.js @@ -7,9 +7,8 @@ // // JSON.stringify makes a valid JS string but leaves `'` alone, so an entry id // of x' onmouseover='alert(1) closed the single-quoted attribute and added a -// handler of its own. The live window.updateImageList (plugins_manager.js loads -// last, so its copy beats the file-upload widget's) put the uploaded file's -// original name into the markup unescaped. +// handler of its own. (The uploaded-image list is no longer built here: the +// file-upload widget owns it, and test_file_upload_widget.js covers it.) // // Each case renders with the shipped function, parses the tag the way a browser // does (quoted attribute values, entities decoded), and checks two things: no @@ -54,6 +53,7 @@ global.document = { createElement: () => new TextEl(), }; global.window = global; +require('../led_escape').install(window); global.pluginLog = () => {}; global.isStorePluginInstalled = () => false; global.isNewPlugin = () => false; @@ -68,8 +68,6 @@ eval([ ].map(extract).join('\n') + '\nglobal.jsStringAttr = jsStringAttr; global.escapeHtml = escapeHtml;' + '\nglobal.renderPluginStore = renderPluginStore; global.renderSavedRepositories = renderSavedRepositories;' + '\nglobal.renderCustomRegistryPlugins = renderCustomRegistryPlugins; global.escapeAttribute = escapeAttribute;'); -// eslint-disable-next-line no-eval -eval(extract('window.updateImageList = function(fieldId, images) {')); // ── minimal HTML start-tag tokenizer ─────────────────────────────────────── function decodeEntities(s) { @@ -190,26 +188,5 @@ for (const hostile of [SQ, DQ, AMP]) { calls.length === 1 && calls[0][0] === 'removeSavedRepository' && calls[0][1] === hostile, calls); } -console.log('\n5. live window.updateImageList escapes the uploaded file name'); -window.getUploadConfig = () => ({ plugin_id: SQ }); -window.currentPluginConfig = null; -{ - const name = '' + DQ + '.png'; - window.updateImageList('f', [{ id: SQ, path: 'assets/x".png', filename: DQ, original_filename: name, size: 1 }]); - const html = els['f_image_list'].innerHTML; - ok('no raw markup from original_filename', !html.includes(', only the template attributes', - imgs.length === 1 && names(imgs[0]).join(',') === 'src,alt,loading,decoding,class,onerror', imgs.map(names)); - ok('alt carries the stored filename as text', attr(imgs[0], 'alt') === DQ, imgs[0]); - const buttons = tags(html, 'button'); - ok('two buttons, no extra attributes', - buttons.length === 2 && buttons.every(b => names(b).join(',') === 'type,onclick,class,title,aria-label'), - buttons.map(names)); - const del = runHandler(attr(buttons[1], 'onclick')); - ok('delete handler gets field, image and plugin ids intact', - del.length === 1 && del[0][0] === 'deleteUploadedImage' && del[0][1] === 'f' && del[0][2] === SQ && del[0][3] === SQ, del); -} - console.log(`\n${pass} passed, ${fail} failed\n`); process.exit(fail ? 1 : 0); diff --git a/test/js/unit/test_plugin_action_delegation.js b/test/js/unit/test_plugin_action_delegation.js index a65cc9ee..882a79b3 100644 --- a/test/js/unit/test_plugin_action_delegation.js +++ b/test/js/unit/test_plugin_action_delegation.js @@ -24,7 +24,7 @@ function slice(startMarker, endMarker) { return SRC.slice(a, b); } -const GLOBAL_DELEGATION = slice('(function setupGlobalEventDelegation() {', '// Note: configurePlugin'); +const GLOBAL_DELEGATION = slice('(function setupGlobalEventDelegation() {', '// GitHub Token Collapse Handler'); const HANDLER = slice('function handlePluginAction(event) {', 'function findInstalledPlugin(pluginId)'); let pass = 0, fail = 0; diff --git a/test/js/unit/test_render_cards.js b/test/js/unit/test_render_cards.js index 8faae80c..741a7824 100644 --- a/test/js/unit/test_render_cards.js +++ b/test/js/unit/test_render_cards.js @@ -30,12 +30,13 @@ global.document = { createElement: () => new FakeEl(), }; global.window = global; +require('../led_escape').install(window); global.pluginLog = () => {}; global.PLUGIN_DEBUG = false; global.debugLog = () => {}; function setupInstalledEventDelegation() {} // stubbed; tested separately -eval(slice('function escapeHtml(text)', '\nfunction ', )); +eval(slice('function escapeHtml(text)', '\nfunction isNewPlugin')); eval(slice('function renderInstalledCards(plugins, total)', '// Set up event delegation for plugin action buttons')); diff --git a/web_interface/static/v3/app.js b/web_interface/static/v3/app.js index 51b6f377..e683e6cd 100644 --- a/web_interface/static/v3/app.js +++ b/web_interface/static/v3/app.js @@ -1,6 +1,37 @@ /* global showNotification */ -// LED Matrix v3 JavaScript -// Additional helpers for HTMX and Alpine.js integration +/* + * app.js -- page-wide behaviour that is not the Alpine app itself. + * + * Deferred, first of the scripts at the end of ; after app-shell.js + * and Alpine. + * + * Load order (templates/v3/base.html): + * , blocking: debugLog and theme inline scripts; the htmx loader + * (injects htmx.min.js with a dynamic - + @@ -246,45 +301,6 @@ }); - - - - - - - - @@ -548,7 +564,7 @@