diff --git a/CHANGELOG.md b/CHANGELOG.md index 97a97c0..c18f3bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,20 @@ All notable changes to GreasyBoii will be documented in this file. submissions without the latter in November 2025; GreasyBoii collects no data, so `"none"` is accurate +### Fixed +- `popup.js`'s three list-rendering functions (`renderRules`, `renderReplacementRows`, + `renderLinkResults`) built their HTML by string-concatenating a template literal into + `.innerHTML` — flagged by AMO's linter, and a real bug, not just a lint nit: the + `escapeHtml()` helper they leaned on only escapes for text-node context, not for the + HTML-attribute context it was actually used in (`value="${escapeHtml(...)}"`, + `title="${escapeHtml(...)}"`), so a value containing `"` could break out of the + attribute; a rule's `matchType` also rendered unescaped if it wasn't one of the three + expected values, reachable via a crafted Import Rules JSON file. Rewrote all three to + build DOM nodes directly (`createElement`/`textContent`/property assignment, plus + `createElementNS` for the two static SVG icons) instead of ever parsing a string as + markup — removes the injection surface entirely rather than just escaping harder, and + the now-unused `escapeHtml()` helper is deleted. + ### Notes - Landed Firefox-first (element picker relayed through `storage.local`+`background.js` since the popup closes before a page click, and JS rules injected via diff --git a/chrome/popup.js b/chrome/popup.js index 23d3612..19adf8d 100644 --- a/chrome/popup.js +++ b/chrome/popup.js @@ -87,68 +87,136 @@ function loadRules() { }); } +const SVG_NS = 'http://www.w3.org/2000/svg'; + +// Bootstrap icon path data (static, not user-derived) — built via createElementNS +// rather than innerHTML so nothing in this file ever parses a string as markup. +const EDIT_ICON_PATHS = [ + { d: 'M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z' } +]; +const DELETE_ICON_PATHS = [ + { d: 'M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z' }, + { d: 'M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z', fillRule: 'evenodd' } +]; + +function buildIconSvg(paths) { + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('width', '16'); + svg.setAttribute('height', '16'); + svg.setAttribute('fill', 'currentColor'); + svg.setAttribute('viewBox', '0 0 16 16'); + paths.forEach(({ d, fillRule }) => { + const path = document.createElementNS(SVG_NS, 'path'); + path.setAttribute('d', d); + if (fillRule) path.setAttribute('fill-rule', fillRule); + svg.appendChild(path); + }); + return svg; +} + +function makeBadge(text, extraClass) { + const span = document.createElement('span'); + span.className = `badge rule-badge ${extraClass}`; + span.textContent = text; + return span; +} + +function buildDetailLine(label, value) { + const div = document.createElement('div'); + const strong = document.createElement('strong'); + strong.textContent = label; + div.appendChild(strong); + div.appendChild(document.createTextNode(' ' + (value || ''))); + return div; +} + +function buildRuleActions(rule, index) { + const actions = document.createElement('div'); + actions.className = 'rule-actions'; + + const toggleWrap = document.createElement('div'); + toggleWrap.className = 'form-check form-switch mb-0'; + const toggle = document.createElement('input'); + toggle.className = 'form-check-input rule-toggle'; + toggle.type = 'checkbox'; + toggle.checked = !!rule.enabled; + toggle.dataset.index = index; + toggle.title = 'Enable/Disable'; + toggleWrap.appendChild(toggle); + actions.appendChild(toggleWrap); + + const editBtn = document.createElement('button'); + editBtn.className = 'btn btn-sm btn-outline-light btn-edit-rule'; + editBtn.dataset.index = index; + editBtn.title = 'Edit'; + editBtn.appendChild(buildIconSvg(EDIT_ICON_PATHS)); + actions.appendChild(editBtn); + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'btn btn-sm btn-outline-danger btn-delete-rule'; + deleteBtn.dataset.index = index; + deleteBtn.title = 'Delete'; + deleteBtn.appendChild(buildIconSvg(DELETE_ICON_PATHS)); + actions.appendChild(deleteBtn); + + return actions; +} + +function buildRuleCard(rule, index) { + const card = document.createElement('div'); + card.className = 'rule-card'; + card.dataset.ruleIndex = index; + + const header = document.createElement('div'); + header.className = 'rule-header'; + + const badgeGroup = document.createElement('div'); + badgeGroup.className = 'd-flex align-items-center gap-2 flex-wrap'; + + const nameSpan = document.createElement('span'); + nameSpan.className = 'rule-name'; + nameSpan.textContent = rule.name; + badgeGroup.appendChild(nameSpan); + + const matchTypeLabels = { url: 'URL', element: 'Element', both: 'URL + Element' }; + badgeGroup.appendChild(makeBadge(matchTypeLabels[rule.matchType] || rule.matchType, 'bg-secondary')); + + if (rule.javascript && rule.javascript.trim()) badgeGroup.appendChild(makeBadge('JS', 'bg-warning')); + if (rule.css && rule.css.trim()) badgeGroup.appendChild(makeBadge('CSS', 'bg-info')); + if (rule.autoClickSelector && rule.autoClickSelector.trim()) badgeGroup.appendChild(makeBadge('Click', 'bg-success')); + if (rule.textReplacements && rule.textReplacements.length) badgeGroup.appendChild(makeBadge('Replace', 'bg-primary')); + if (rule.reapplyOnDomChanges) badgeGroup.appendChild(makeBadge('SPA', 'bg-dark border')); + + header.appendChild(badgeGroup); + header.appendChild(buildRuleActions(rule, index)); + card.appendChild(header); + + const details = document.createElement('div'); + details.className = 'rule-details'; + if (rule.matchType === 'url' || rule.matchType === 'both') { + details.appendChild(buildDetailLine('URL:', rule.urlPattern)); + } + if (rule.matchType === 'element' || rule.matchType === 'both') { + details.appendChild(buildDetailLine('Element:', rule.elementSelector)); + } + card.appendChild(details); + + return card; +} + function renderRules() { const rulesList = document.getElementById('rulesList'); const emptyState = document.getElementById('emptyState'); + rulesList.replaceChildren(); + if (currentRules.length === 0) { - rulesList.innerHTML = ''; emptyState.style.display = 'block'; return; } emptyState.style.display = 'none'; - - rulesList.innerHTML = currentRules.map((rule, index) => { - const matchTypeLabel = { - 'url': 'URL', - 'element': 'Element', - 'both': 'URL + Element' - }[rule.matchType] || rule.matchType; - - const hasJS = rule.javascript && rule.javascript.trim(); - const hasCSS = rule.css && rule.css.trim(); - const hasAutoClick = rule.autoClickSelector && rule.autoClickSelector.trim(); - const hasReplacements = rule.textReplacements && rule.textReplacements.length; - - return ` -
-
-
- ${escapeHtml(rule.name)} - ${matchTypeLabel} - ${hasJS ? 'JS' : ''} - ${hasCSS ? 'CSS' : ''} - ${hasAutoClick ? 'Click' : ''} - ${hasReplacements ? 'Replace' : ''} - ${rule.reapplyOnDomChanges ? 'SPA' : ''} -
-
-
- -
- - -
-
-
- ${rule.matchType === 'url' || rule.matchType === 'both' ? `
URL: ${escapeHtml(rule.urlPattern)}
` : ''} - ${rule.matchType === 'element' || rule.matchType === 'both' ? `
Element: ${escapeHtml(rule.elementSelector)}
` : ''} -
-
- `; - }).join(''); - + currentRules.forEach((rule, index) => rulesList.appendChild(buildRuleCard(rule, index))); attachRuleEventListeners(); } @@ -306,12 +374,6 @@ function toggleRule(index) { }); } -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text == null ? '' : text; - return div.innerHTML; -} - // --------------------------------------------------------------------- // Advanced rule fields: text replacement rows + the "pick on page" button // --------------------------------------------------------------------- @@ -352,13 +414,37 @@ function setupAdvancedRuleFields() { function renderReplacementRows(rows) { const list = document.getElementById('textReplacementsList'); - list.innerHTML = rows.map((r, i) => ` -
- - - -
- `).join(''); + list.replaceChildren(); + rows.forEach((r, i) => list.appendChild(buildReplacementRow(r, i))); +} + +function buildReplacementRow(r, index) { + const row = document.createElement('div'); + row.className = 'input-group input-group-sm mb-1 replacement-row'; + row.dataset.index = index; + + const findInput = document.createElement('input'); + findInput.type = 'text'; + findInput.className = 'form-control replacement-find'; + findInput.placeholder = 'Find'; + findInput.value = r.find || ''; + row.appendChild(findInput); + + const replaceInput = document.createElement('input'); + replaceInput.type = 'text'; + replaceInput.className = 'form-control replacement-replace'; + replaceInput.placeholder = 'Replace with'; + replaceInput.value = r.replace || ''; + row.appendChild(replaceInput); + + const removeBtn = document.createElement('button'); + removeBtn.className = 'btn btn-outline-danger remove-replacement-btn'; + removeBtn.type = 'button'; + removeBtn.title = 'Remove'; + removeBtn.textContent = '✕'; + row.appendChild(removeBtn); + + return row; } function collectReplacementRows() { @@ -601,12 +687,39 @@ function setupLinkHarvester() { return; } - linkResults.innerHTML = `
${harvestedLinks.map((link, i) => ` -
- - -
- `).join('')}
${harvestedLinks.length} link(s) found
`; + linkResults.replaceChildren(); + + const scroll = document.createElement('div'); + scroll.className = 'tool-scroll'; + + harvestedLinks.forEach((link, i) => { + const wrap = document.createElement('div'); + wrap.className = 'form-check'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'form-check-input link-check'; + checkbox.id = `link-${i}`; + checkbox.dataset.index = i; + checkbox.checked = true; + wrap.appendChild(checkbox); + + const label = document.createElement('label'); + label.className = 'form-check-label text-truncate d-block'; + label.htmlFor = `link-${i}`; + label.title = link.url; + label.textContent = link.text || link.url; + wrap.appendChild(label); + + scroll.appendChild(wrap); + }); + linkResults.appendChild(scroll); + + const countLine = document.createElement('div'); + countLine.className = 'text-muted mt-1'; + countLine.textContent = `${harvestedLinks.length} link(s) found`; + linkResults.appendChild(countLine); + openLinksBtn.disabled = false; exportLinksBtn.disabled = false; } diff --git a/firefox/popup.js b/firefox/popup.js index 02cfc9d..d99ee5f 100644 --- a/firefox/popup.js +++ b/firefox/popup.js @@ -87,68 +87,136 @@ function loadRules() { }); } +const SVG_NS = 'http://www.w3.org/2000/svg'; + +// Bootstrap icon path data (static, not user-derived) — built via createElementNS +// rather than innerHTML so nothing in this file ever parses a string as markup. +const EDIT_ICON_PATHS = [ + { d: 'M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z' } +]; +const DELETE_ICON_PATHS = [ + { d: 'M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z' }, + { d: 'M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z', fillRule: 'evenodd' } +]; + +function buildIconSvg(paths) { + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('width', '16'); + svg.setAttribute('height', '16'); + svg.setAttribute('fill', 'currentColor'); + svg.setAttribute('viewBox', '0 0 16 16'); + paths.forEach(({ d, fillRule }) => { + const path = document.createElementNS(SVG_NS, 'path'); + path.setAttribute('d', d); + if (fillRule) path.setAttribute('fill-rule', fillRule); + svg.appendChild(path); + }); + return svg; +} + +function makeBadge(text, extraClass) { + const span = document.createElement('span'); + span.className = `badge rule-badge ${extraClass}`; + span.textContent = text; + return span; +} + +function buildDetailLine(label, value) { + const div = document.createElement('div'); + const strong = document.createElement('strong'); + strong.textContent = label; + div.appendChild(strong); + div.appendChild(document.createTextNode(' ' + (value || ''))); + return div; +} + +function buildRuleActions(rule, index) { + const actions = document.createElement('div'); + actions.className = 'rule-actions'; + + const toggleWrap = document.createElement('div'); + toggleWrap.className = 'form-check form-switch mb-0'; + const toggle = document.createElement('input'); + toggle.className = 'form-check-input rule-toggle'; + toggle.type = 'checkbox'; + toggle.checked = !!rule.enabled; + toggle.dataset.index = index; + toggle.title = 'Enable/Disable'; + toggleWrap.appendChild(toggle); + actions.appendChild(toggleWrap); + + const editBtn = document.createElement('button'); + editBtn.className = 'btn btn-sm btn-outline-light btn-edit-rule'; + editBtn.dataset.index = index; + editBtn.title = 'Edit'; + editBtn.appendChild(buildIconSvg(EDIT_ICON_PATHS)); + actions.appendChild(editBtn); + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'btn btn-sm btn-outline-danger btn-delete-rule'; + deleteBtn.dataset.index = index; + deleteBtn.title = 'Delete'; + deleteBtn.appendChild(buildIconSvg(DELETE_ICON_PATHS)); + actions.appendChild(deleteBtn); + + return actions; +} + +function buildRuleCard(rule, index) { + const card = document.createElement('div'); + card.className = 'rule-card'; + card.dataset.ruleIndex = index; + + const header = document.createElement('div'); + header.className = 'rule-header'; + + const badgeGroup = document.createElement('div'); + badgeGroup.className = 'd-flex align-items-center gap-2 flex-wrap'; + + const nameSpan = document.createElement('span'); + nameSpan.className = 'rule-name'; + nameSpan.textContent = rule.name; + badgeGroup.appendChild(nameSpan); + + const matchTypeLabels = { url: 'URL', element: 'Element', both: 'URL + Element' }; + badgeGroup.appendChild(makeBadge(matchTypeLabels[rule.matchType] || rule.matchType, 'bg-secondary')); + + if (rule.javascript && rule.javascript.trim()) badgeGroup.appendChild(makeBadge('JS', 'bg-warning')); + if (rule.css && rule.css.trim()) badgeGroup.appendChild(makeBadge('CSS', 'bg-info')); + if (rule.autoClickSelector && rule.autoClickSelector.trim()) badgeGroup.appendChild(makeBadge('Click', 'bg-success')); + if (rule.textReplacements && rule.textReplacements.length) badgeGroup.appendChild(makeBadge('Replace', 'bg-primary')); + if (rule.reapplyOnDomChanges) badgeGroup.appendChild(makeBadge('SPA', 'bg-dark border')); + + header.appendChild(badgeGroup); + header.appendChild(buildRuleActions(rule, index)); + card.appendChild(header); + + const details = document.createElement('div'); + details.className = 'rule-details'; + if (rule.matchType === 'url' || rule.matchType === 'both') { + details.appendChild(buildDetailLine('URL:', rule.urlPattern)); + } + if (rule.matchType === 'element' || rule.matchType === 'both') { + details.appendChild(buildDetailLine('Element:', rule.elementSelector)); + } + card.appendChild(details); + + return card; +} + function renderRules() { const rulesList = document.getElementById('rulesList'); const emptyState = document.getElementById('emptyState'); + rulesList.replaceChildren(); + if (currentRules.length === 0) { - rulesList.innerHTML = ''; emptyState.style.display = 'block'; return; } emptyState.style.display = 'none'; - - rulesList.innerHTML = currentRules.map((rule, index) => { - const matchTypeLabel = { - 'url': 'URL', - 'element': 'Element', - 'both': 'URL + Element' - }[rule.matchType] || rule.matchType; - - const hasJS = rule.javascript && rule.javascript.trim(); - const hasCSS = rule.css && rule.css.trim(); - const hasAutoClick = rule.autoClickSelector && rule.autoClickSelector.trim(); - const hasReplacements = rule.textReplacements && rule.textReplacements.length; - - return ` -
-
-
- ${escapeHtml(rule.name)} - ${matchTypeLabel} - ${hasJS ? 'JS' : ''} - ${hasCSS ? 'CSS' : ''} - ${hasAutoClick ? 'Click' : ''} - ${hasReplacements ? 'Replace' : ''} - ${rule.reapplyOnDomChanges ? 'SPA' : ''} -
-
-
- -
- - -
-
-
- ${rule.matchType === 'url' || rule.matchType === 'both' ? `
URL: ${escapeHtml(rule.urlPattern)}
` : ''} - ${rule.matchType === 'element' || rule.matchType === 'both' ? `
Element: ${escapeHtml(rule.elementSelector)}
` : ''} -
-
- `; - }).join(''); - + currentRules.forEach((rule, index) => rulesList.appendChild(buildRuleCard(rule, index))); attachRuleEventListeners(); } @@ -306,12 +374,6 @@ function toggleRule(index) { }); } -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text == null ? '' : text; - return div.innerHTML; -} - // --------------------------------------------------------------------- // Advanced rule fields: text replacement rows + the "pick on page" button // --------------------------------------------------------------------- @@ -352,13 +414,37 @@ function setupAdvancedRuleFields() { function renderReplacementRows(rows) { const list = document.getElementById('textReplacementsList'); - list.innerHTML = rows.map((r, i) => ` -
- - - -
- `).join(''); + list.replaceChildren(); + rows.forEach((r, i) => list.appendChild(buildReplacementRow(r, i))); +} + +function buildReplacementRow(r, index) { + const row = document.createElement('div'); + row.className = 'input-group input-group-sm mb-1 replacement-row'; + row.dataset.index = index; + + const findInput = document.createElement('input'); + findInput.type = 'text'; + findInput.className = 'form-control replacement-find'; + findInput.placeholder = 'Find'; + findInput.value = r.find || ''; + row.appendChild(findInput); + + const replaceInput = document.createElement('input'); + replaceInput.type = 'text'; + replaceInput.className = 'form-control replacement-replace'; + replaceInput.placeholder = 'Replace with'; + replaceInput.value = r.replace || ''; + row.appendChild(replaceInput); + + const removeBtn = document.createElement('button'); + removeBtn.className = 'btn btn-outline-danger remove-replacement-btn'; + removeBtn.type = 'button'; + removeBtn.title = 'Remove'; + removeBtn.textContent = '✕'; + row.appendChild(removeBtn); + + return row; } function collectReplacementRows() { @@ -601,12 +687,39 @@ function setupLinkHarvester() { return; } - linkResults.innerHTML = `
${harvestedLinks.map((link, i) => ` -
- - -
- `).join('')}
${harvestedLinks.length} link(s) found
`; + linkResults.replaceChildren(); + + const scroll = document.createElement('div'); + scroll.className = 'tool-scroll'; + + harvestedLinks.forEach((link, i) => { + const wrap = document.createElement('div'); + wrap.className = 'form-check'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'form-check-input link-check'; + checkbox.id = `link-${i}`; + checkbox.dataset.index = i; + checkbox.checked = true; + wrap.appendChild(checkbox); + + const label = document.createElement('label'); + label.className = 'form-check-label text-truncate d-block'; + label.htmlFor = `link-${i}`; + label.title = link.url; + label.textContent = link.text || link.url; + wrap.appendChild(label); + + scroll.appendChild(wrap); + }); + linkResults.appendChild(scroll); + + const countLine = document.createElement('div'); + countLine.className = 'text-muted mt-1'; + countLine.textContent = `${harvestedLinks.length} link(s) found`; + linkResults.appendChild(countLine); + openLinksBtn.disabled = false; exportLinksBtn.disabled = false; }