fix(web): resolve Codacy findings — DOM building over innerHTML, Map callbacks, misc lint

Verified each of the 27 reported findings against current code; all fixed
except one rule class skipped with reason below.

- app-early.js: plugin tab buttons are now built with createElement/
  createTextNode instead of innerHTML template strings (icon class and name
  come from plugin manifests - semi-trusted input; the old code escaped the
  name but interpolated the icon class). Both construction sites. Also the
  forEach arrow no longer returns tab.remove()'s value.
- plugin-order-list.js: rows, empty state, and error state all built with
  DOM APIs - the file no longer contains innerHTML at all (the now-unneeded
  escapeHtml/escapeAttr helpers are removed); MODE_LABELS is a Map so the
  vegas-mode lookup can't hit prototype properties.
- notification.js: actionCallbacks is a Map (get/set/delete) instead of a
  plain object - resolves the object-injection-sink and dynamic-delete
  findings; triggerAction also type-checks the callback.
- htmx-config.js: unused catch binding dropped; var -> const in the
  afterSettle handler; the swapped-<script> re-execution reads/writes
  textContent instead of innerHTML; the diagnostic form payload uses a
  null-prototype object so a field named __proto__ can't pollute.
- custom-feeds-helpers.js DELETED (with its script tag): all three of its
  functions (addCustomFeedRow, removeCustomFeedRow,
  handleCustomFeedLogoUpload) are shadowed by the deferred
  widgets/custom-feeds.js window assignments, which always win at call time
  - the copies were dead even when they lived inline in base.html. This
  also resolves the unused-function and unused-variable findings there.

Skipped: 4x "Non-serializable expression must be wrapped with $(...)" in
app-early.js - that rule targets code crossing a browser-automation
serialization boundary (e.g. page.evaluate); these are ordinary arrow
functions in plain browser code with no such boundary.

Validation: all 40 web tests pass (incl. the static-asset reference audit,
which confirms no template still points at the deleted file); Jinja parse OK.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
This commit is contained in:
ChuckBuilds
2026-07-16 13:11:17 -04:00
co-authored by Claude Sonnet 5
parent e1e26e8eab
commit e00d10ce60
6 changed files with 87 additions and 326 deletions
+16 -7
View File
@@ -51,7 +51,7 @@
if (pluginTabsRow && pluginTabsNav && plugins.length > 0) {
// Clear existing plugin tabs (except Plugin Manager)
const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
existingTabs.forEach(tab => tab.remove());
existingTabs.forEach(tab => { tab.remove(); });
// Add tabs for each installed plugin
plugins.forEach(plugin => {
@@ -72,8 +72,14 @@
}
}
};
const iconClass = (plugin.icon || 'fas fa-puzzle-piece').replace(/"/g, '&quot;');
tabButton.innerHTML = `<i class="${iconClass}"></i>${(plugin.name || plugin.id).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}`;
// Built with DOM APIs (no innerHTML): the icon class and
// name come from plugin manifests, which are only
// semi-trusted input.
const tabIcon = document.createElement('i');
tabIcon.className = plugin.icon || 'fas fa-puzzle-piece';
tabButton.textContent = '';
tabButton.appendChild(tabIcon);
tabButton.appendChild(document.createTextNode(plugin.name || plugin.id));
pluginTabsNav.appendChild(tabButton);
});
debugLog('[GLOBAL] Updated plugin tabs directly:', plugins.length, 'tabs added');
@@ -331,10 +337,13 @@
this.updatePluginTabStates();
}
};
const div = document.createElement('div');
div.textContent = plugin.name || plugin.id;
const iconClass = (plugin.icon || 'fas fa-puzzle-piece').replace(/"/g, '&quot;');
tabButton.innerHTML = `<i class="${iconClass}"></i>${div.innerHTML}`;
// DOM APIs instead of innerHTML: manifest
// icon/name are semi-trusted input.
const tabIcon = document.createElement('i');
tabIcon.className = plugin.icon || 'fas fa-puzzle-piece';
tabButton.textContent = '';
tabButton.appendChild(tabIcon);
tabButton.appendChild(document.createTextNode(plugin.name || plugin.id));
pluginTabsNav.appendChild(tabButton);
});
debugLog('[STUB] updatePluginTabs: Added', this.installedPlugins.length, 'plugin tabs');
@@ -1,263 +0,0 @@
/* global debugLog */
// Custom feeds table helper functions
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
function addCustomFeedRow(fieldId, fullKey, maxItems, pluginId) {
const tbody = document.getElementById(fieldId + '_tbody');
if (!tbody) return;
const currentRows = tbody.querySelectorAll('.custom-feed-row');
if (currentRows.length >= maxItems) {
alert(`Maximum ${maxItems} feeds allowed`);
return;
}
const newIndex = currentRows.length;
const newRow = document.createElement('tr');
newRow.className = 'custom-feed-row';
newRow.setAttribute('data-index', newIndex);
// Create name cell
const nameCell = document.createElement('td');
nameCell.className = 'px-4 py-3 whitespace-nowrap';
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.name = `${fullKey}.${newIndex}.name`;
nameInput.value = '';
nameInput.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
nameInput.placeholder = 'Feed Name';
nameInput.required = true;
nameCell.appendChild(nameInput);
// Create URL cell
const urlCell = document.createElement('td');
urlCell.className = 'px-4 py-3 whitespace-nowrap';
const urlInput = document.createElement('input');
urlInput.type = 'url';
urlInput.name = `${fullKey}.${newIndex}.url`;
urlInput.value = '';
urlInput.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
urlInput.placeholder = 'https://example.com/feed';
urlInput.required = true;
urlCell.appendChild(urlInput);
// Create logo cell
const logoCell = document.createElement('td');
logoCell.className = 'px-4 py-3 whitespace-nowrap';
const logoContainer = document.createElement('div');
logoContainer.className = 'flex items-center space-x-2';
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.id = `${fieldId}_logo_${newIndex}`;
fileInput.accept = 'image/png,image/jpeg,image/bmp,image/gif';
fileInput.style.display = 'none';
fileInput.dataset.index = String(newIndex);
// Use addEventListener with dataset index to allow reindexing
fileInput.addEventListener('change', function(e) {
const idx = parseInt(e.target.dataset.index || '0', 10);
handleCustomFeedLogoUpload(e, fieldId, idx, pluginId, fullKey);
});
const uploadButton = document.createElement('button');
uploadButton.type = 'button';
uploadButton.className = 'px-2 py-1 text-xs bg-gray-200 hover:bg-gray-300 rounded';
// Use fileInput directly instead of getElementById for reindexing compatibility
uploadButton.addEventListener('click', function() {
fileInput.click();
});
const uploadIcon = document.createElement('i');
uploadIcon.className = 'fas fa-upload mr-1';
uploadButton.appendChild(uploadIcon);
uploadButton.appendChild(document.createTextNode(' Upload'));
const noLogoSpan = document.createElement('span');
noLogoSpan.className = 'text-xs text-gray-400';
noLogoSpan.textContent = 'No logo';
logoContainer.appendChild(fileInput);
logoContainer.appendChild(uploadButton);
logoContainer.appendChild(noLogoSpan);
logoCell.appendChild(logoContainer);
// Create enabled cell
const enabledCell = document.createElement('td');
enabledCell.className = 'px-4 py-3 whitespace-nowrap text-center';
const enabledInput = document.createElement('input');
enabledInput.type = 'checkbox';
enabledInput.name = `${fullKey}.${newIndex}.enabled`;
enabledInput.checked = true;
enabledInput.value = 'true';
enabledInput.className = 'h-4 w-4 text-blue-600';
enabledCell.appendChild(enabledInput);
// Create remove cell
const removeCell = document.createElement('td');
removeCell.className = 'px-4 py-3 whitespace-nowrap text-center';
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
// Use addEventListener instead of string-based onclick to prevent injection
removeButton.addEventListener('click', function() {
removeCustomFeedRow(this);
});
const removeIcon = document.createElement('i');
removeIcon.className = 'fas fa-trash';
removeButton.appendChild(removeIcon);
removeCell.appendChild(removeButton);
// Append all cells to row
newRow.appendChild(nameCell);
newRow.appendChild(urlCell);
newRow.appendChild(logoCell);
newRow.appendChild(enabledCell);
newRow.appendChild(removeCell);
tbody.appendChild(newRow);
}
function removeCustomFeedRow(button) {
const row = button.closest('tr');
if (!row) return;
if (confirm('Remove this feed?')) {
const tbody = row.parentElement;
if (!tbody) return;
row.remove();
// Re-index remaining rows
const rows = tbody.querySelectorAll('.custom-feed-row');
rows.forEach((r, index) => {
const oldIndex = r.getAttribute('data-index');
r.setAttribute('data-index', index);
// Update all input names with new index
r.querySelectorAll('input, button').forEach(input => {
const name = input.getAttribute('name');
if (name) {
// Replace pattern like "feeds.custom_feeds.0.name" with "feeds.custom_feeds.1.name"
input.setAttribute('name', name.replace(/\.\d+\./, `.${index}.`));
}
const id = input.id;
if (id) {
// Keep IDs aligned after reindex (supports both _logo_<n> and _logo_preview_<n>)
input.id = id
.replace(/_logo_preview_\d+$/, `_logo_preview_${index}`)
.replace(/_logo_\d+$/, `_logo_${index}`);
}
// Keep dataset index aligned so event handlers remain correct after reindex
if (input.dataset && 'index' in input.dataset) {
input.dataset.index = String(index);
}
});
});
}
}
function handleCustomFeedLogoUpload(event, fieldId, index, pluginId, fullKey) {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
formData.append('plugin_id', pluginId);
fetch('/api/v3/plugins/assets/upload', {
method: 'POST',
body: formData
})
.then(response => {
// Check HTTP status before parsing JSON
if (!response.ok) {
return response.text().then(text => {
throw new Error(`Upload failed: ${response.status} ${response.statusText}${text ? ': ' + text : ''}`);
});
}
return response.json();
})
.then(data => {
if (data.status === 'success' && data.data && data.data.files && data.data.files.length > 0) {
const uploadedFile = data.data.files[0];
const row = document.querySelector(`#${fieldId}_tbody tr[data-index="${index}"]`);
if (row) {
const logoCell = row.querySelector('td:nth-child(3)');
const existingPathInput = logoCell.querySelector('input[name*=".logo.path"]');
const existingIdInput = logoCell.querySelector('input[name*=".logo.id"]');
const pathName = existingPathInput ? existingPathInput.name : `${fullKey}.${index}.logo.path`;
const idName = existingIdInput ? existingIdInput.name : `${fullKey}.${index}.logo.id`;
// Normalize path: remove leading slashes, then add single leading slash
const normalizedPath = String(uploadedFile.path || '').replace(/^\/+/, '');
const imageSrc = '/' + normalizedPath;
// Clear logoCell and build DOM safely to prevent XSS
logoCell.textContent = ''; // Clear existing content
// Create container div
const container = document.createElement('div');
container.className = 'flex items-center space-x-2';
// Create file input
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.id = `${fieldId}_logo_${index}`;
fileInput.accept = 'image/png,image/jpeg,image/bmp,image/gif';
fileInput.style.display = 'none';
fileInput.dataset.index = String(index);
// Use addEventListener with dataset index to allow reindexing
fileInput.addEventListener('change', function(e) {
const idx = parseInt(e.target.dataset.index || '0', 10);
handleCustomFeedLogoUpload(e, fieldId, idx, pluginId, fullKey);
});
// Create upload button
const uploadButton = document.createElement('button');
uploadButton.type = 'button';
uploadButton.className = 'px-2 py-1 text-xs bg-gray-200 hover:bg-gray-300 rounded';
// Use fileInput directly instead of getElementById for reindexing compatibility
uploadButton.addEventListener('click', function() {
fileInput.click();
});
const uploadIcon = document.createElement('i');
uploadIcon.className = 'fas fa-upload mr-1';
uploadButton.appendChild(uploadIcon);
uploadButton.appendChild(document.createTextNode(' Upload'));
// Create img element - use normalized path, set src via property to prevent XSS
const img = document.createElement('img');
img.src = imageSrc; // Use property assignment with normalized path
img.alt = 'Logo';
img.className = 'w-8 h-8 object-cover rounded border';
img.id = `${fieldId}_logo_preview_${index}`;
// Create hidden input for path - set value via property to prevent XSS
const pathInput = document.createElement('input');
pathInput.type = 'hidden';
pathInput.name = pathName;
pathInput.value = imageSrc;
// Create hidden input for id - set value via property to prevent XSS
const idInput = document.createElement('input');
idInput.type = 'hidden';
idInput.name = idName;
idInput.value = String(uploadedFile.id); // Ensure it's a string
// Append all elements to container
container.appendChild(fileInput);
container.appendChild(uploadButton);
container.appendChild(img);
container.appendChild(pathInput);
container.appendChild(idInput);
// Append container to logoCell
logoCell.appendChild(container);
}
// Allow re-uploading the same file (change event won't fire otherwise)
event.target.value = '';
} else {
alert('Upload failed: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
console.error('Upload error:', error);
alert('Upload failed: ' + error.message);
});
}
+6 -6
View File
@@ -133,7 +133,7 @@
// For form submissions, log the form data
if (target && target.tagName === 'FORM') {
const formData = new FormData(target);
const formPayload = {};
const formPayload = Object.create(null);
for (const [key, value] of formData.entries()) {
formPayload[key] = value;
}
@@ -149,7 +149,7 @@
validation_errors: errorData.validation_errors,
context: errorData.context
});
} catch (e) {
} catch {
console.error('Error response (non-JSON):', xhr.responseText.substring(0, 500));
}
}
@@ -167,11 +167,11 @@
const scripts = event.detail.target.querySelectorAll('script');
scripts.forEach(function(oldScript) {
try {
if (oldScript.innerHTML.trim() || oldScript.src) {
if (oldScript.textContent.trim() || oldScript.src) {
const newScript = document.createElement('script');
if (oldScript.src) newScript.src = oldScript.src;
if (oldScript.type) newScript.type = oldScript.type;
if (oldScript.innerHTML) newScript.textContent = oldScript.innerHTML;
if (oldScript.textContent) newScript.textContent = oldScript.textContent;
if (oldScript.parentNode) {
oldScript.parentNode.insertBefore(newScript, oldScript);
oldScript.parentNode.removeChild(oldScript);
@@ -195,8 +195,8 @@
// containers only) so modals and plugin config panels can still reload.
document.body.addEventListener('htmx:afterSettle', function(event) {
if (event.detail && event.detail.target) {
var target = event.detail.target;
var trigger = target.getAttribute('hx-trigger') || '';
const target = event.detail.target;
const trigger = target.getAttribute('hx-trigger') || '';
if (trigger.includes('loadtab')) {
target.setAttribute('data-loaded', 'true');
}
@@ -59,8 +59,9 @@
// Track active notifications
let activeNotifications = [];
// onAction callbacks for notifications with an inline action button,
// keyed by notification id (cleaned up on dismiss).
const actionCallbacks = {};
// keyed by notification id (cleaned up on dismiss). A Map rather than a
// plain object so ids can never collide with prototype properties.
const actionCallbacks = new Map();
let notificationCounter = 0;
/**
@@ -116,7 +117,7 @@
// Remove from tracking array
activeNotifications = activeNotifications.filter(id => id !== notificationId);
delete actionCallbacks[notificationId];
actionCallbacks.delete(notificationId);
}
/**
@@ -166,7 +167,7 @@
// The callback is stored by id and invoked via triggerAction, which
// also dismisses the notification.
if (options.actionLabel && typeof options.onAction === 'function') {
actionCallbacks[notificationId] = options.onAction;
actionCallbacks.set(notificationId, options.onAction);
html += `
<button type="button"
onclick="window.LEDMatrixWidgets.get('notification').triggerAction('${notificationId}')"
@@ -251,9 +252,9 @@
* @param {string} notificationId - Notification ID whose action to run
*/
triggerAction: function(notificationId) {
const cb = actionCallbacks[notificationId];
const cb = actionCallbacks.get(notificationId);
removeNotification(notificationId);
if (cb) cb();
if (typeof cb === 'function') cb();
},
/**
@@ -21,21 +21,11 @@
(function() {
'use strict';
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text == null ? '' : String(text);
return div.innerHTML;
}
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
const MODE_LABELS = {
'scroll': { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' },
'fixed': { label: 'Fixed', icon: 'fa-square', color: 'text-green-600' },
'static': { label: 'Static', icon: 'fa-pause', color: 'text-orange-600' }
};
const MODE_LABELS = new Map([
['scroll', { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' }],
['fixed', { label: 'Fixed', icon: 'fa-square', color: 'text-green-600' }],
['static', { label: 'Static', icon: 'fa-pause', color: 'text-orange-600' }]
]);
function init(options) {
const container = document.getElementById(options.containerId);
@@ -109,7 +99,11 @@
const allPlugins = (data.data && data.data.plugins) || data.plugins || [];
const plugins = allPlugins.filter(p => p.enabled);
if (plugins.length === 0) {
container.innerHTML = '<p class="text-sm text-gray-500 italic">No enabled plugins</p>';
const empty = document.createElement('p');
empty.className = 'text-sm text-gray-500 italic';
empty.textContent = 'No enabled plugins';
container.textContent = '';
container.appendChild(empty);
return;
}
@@ -132,40 +126,55 @@
if (!orderedPlugins.find(p => p.id === plugin.id)) orderedPlugins.push(plugin);
});
let html = '';
// Rows are built with DOM APIs rather than innerHTML — plugin
// ids/names come from installed manifests (semi-trusted).
container.textContent = '';
orderedPlugins.forEach(plugin => {
const safePluginId = escapeAttr(plugin.id);
const safePluginName = escapeHtml(plugin.name || plugin.id);
let rowInner;
const row = document.createElement('div');
row.className = 'flex items-center p-2 bg-gray-50 rounded border border-gray-200 cursor-move plugin-order-item';
row.dataset.pluginId = plugin.id;
row.draggable = true;
const grip = document.createElement('i');
grip.className = 'fas fa-grip-vertical text-gray-400 mr-3';
row.appendChild(grip);
if (excludedInput) {
const isExcluded = excluded.includes(plugin.id);
rowInner = `
<label class="flex items-center flex-1">
<input type="checkbox"
class="plugin-order-include h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2"
${!isExcluded ? 'checked' : ''}>
<span class="text-sm font-medium text-gray-700">${safePluginName}</span>
</label>`;
const label = document.createElement('label');
label.className = 'flex items-center flex-1';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'plugin-order-include h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2';
checkbox.checked = !isExcluded;
const name = document.createElement('span');
name.className = 'text-sm font-medium text-gray-700';
name.textContent = plugin.name || plugin.id;
label.appendChild(checkbox);
label.appendChild(name);
row.appendChild(label);
} else {
rowInner = `<span class="text-sm font-medium text-gray-700 flex-1">${safePluginName}</span>`;
const name = document.createElement('span');
name.className = 'text-sm font-medium text-gray-700 flex-1';
name.textContent = plugin.name || plugin.id;
row.appendChild(name);
}
let badge = '';
if (options.showVegasModeBadge) {
const vegasMode = plugin.vegas_mode || plugin.vegas_content_type || 'fixed';
const modeInfo = MODE_LABELS[vegasMode] || MODE_LABELS['fixed'];
badge = `
<span class="text-xs ${modeInfo.color} ml-2" title="Vegas display mode: ${modeInfo.label}">
<i class="fas ${modeInfo.icon} mr-1"></i>${modeInfo.label}
</span>`;
const modeInfo = MODE_LABELS.get(vegasMode) || MODE_LABELS.get('fixed');
const badge = document.createElement('span');
badge.className = `text-xs ${modeInfo.color} ml-2`;
badge.title = `Vegas display mode: ${modeInfo.label}`;
const badgeIcon = document.createElement('i');
badgeIcon.className = `fas ${modeInfo.icon} mr-1`;
badge.appendChild(badgeIcon);
badge.appendChild(document.createTextNode(modeInfo.label));
row.appendChild(badge);
}
html += `
<div class="flex items-center p-2 bg-gray-50 rounded border border-gray-200 cursor-move plugin-order-item"
data-plugin-id="${safePluginId}" draggable="true">
<i class="fas fa-grip-vertical text-gray-400 mr-3"></i>
${rowInner}${badge}
</div>`;
container.appendChild(row);
});
container.innerHTML = html;
setupDragAndDrop();
container.querySelectorAll('.plugin-order-include').forEach(checkbox => {
@@ -175,7 +184,11 @@
})
.catch(error => {
console.error('Error fetching plugins:', error);
container.innerHTML = '<p class="text-sm text-red-500">Error loading plugins</p>';
const err = document.createElement('p');
err.className = 'text-sm text-red-500';
err.textContent = 'Error loading plugins';
container.textContent = '';
container.appendChild(err);
});
}