diff --git a/web_interface/static/v3/js/app-early.js b/web_interface/static/v3/js/app-early.js index 0da92f21..7eff0307 100644 --- a/web_interface/static/v3/js/app-early.js +++ b/web_interface/static/v3/js/app-early.js @@ -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, '"'); - tabButton.innerHTML = `${(plugin.name || plugin.id).replace(/&/g, '&').replace(//g, '>')}`; + // 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, '"'); - tabButton.innerHTML = `${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'); diff --git a/web_interface/static/v3/js/custom-feeds-helpers.js b/web_interface/static/v3/js/custom-feeds-helpers.js deleted file mode 100644 index 36cced87..00000000 --- a/web_interface/static/v3/js/custom-feeds-helpers.js +++ /dev/null @@ -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_ and _logo_preview_) - 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); - }); - } diff --git a/web_interface/static/v3/js/htmx-config.js b/web_interface/static/v3/js/htmx-config.js index 2de0f79d..62da9584 100644 --- a/web_interface/static/v3/js/htmx-config.js +++ b/web_interface/static/v3/js/htmx-config.js @@ -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'); } diff --git a/web_interface/static/v3/js/widgets/notification.js b/web_interface/static/v3/js/widgets/notification.js index f1cf9405..14805bb6 100644 --- a/web_interface/static/v3/js/widgets/notification.js +++ b/web_interface/static/v3/js/widgets/notification.js @@ -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 += ` p.enabled); if (plugins.length === 0) { - container.innerHTML = 'No enabled plugins'; + 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 = ` - - - ${safePluginName} - `; + 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 = `${safePluginName}`; + 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 = ` - - ${modeInfo.label} - `; + 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 += ` - - - ${rowInner}${badge} - `; + + 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 = 'Error loading plugins'; + const err = document.createElement('p'); + err.className = 'text-sm text-red-500'; + err.textContent = 'Error loading plugins'; + container.textContent = ''; + container.appendChild(err); }); } diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 111cda65..7be1384b 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -1005,8 +1005,9 @@ - - +
No enabled plugins
Error loading plugins