Fix: mirrored playlist action buttons dead when name has an apostrophe

A mirrored playlist named with an apostrophe (e.g. "Road trip-The
Rolfe's") rendered dead action buttons. _escAttr HTML-escapes ' to ',
but it was used to inject the name into a single-quoted JS string inside an
inline onclick. The HTML parser decodes ' back to a bare ' BEFORE the JS
parser runs, producing an unterminated string literal -> SyntaxError -> the
whole handler fails to compile.

Two symptoms (both reproduced with the real name + the literal line-524
onclick template): clicking the X delete never ran event.stopPropagation(),
so the click bubbled to the card and opened the track preview instead; and
the preview's "Delete Mirror" silently did nothing (no DELETE request, no
log). Plain names ("Classic Rock") were unaffected, which is why it looked
intermittent.

Add a dedicated _escJs() that backslash-escapes the JS metacharacters (\, ')
first, then HTML-escapes the attribute-breaking chars - correct for a
single-quoted JS string inside a double-quoted HTML attribute. Convert all 16
inline-onclick string-argument sites to it: mirrored card (clear/Auto-Sync/
link/delete) and preview modal, plus the same latent bug in pool Fix Match /
Rematch, group bulk-toggle/rename/delete, and automation history/group/delete.
Genuine HTML-attribute usages (class/value/data-*/title/option) stay on
_escAttr where it is correct.

Tests: tests/static/test_stats_automations_esc.mjs extracts the real _escJs/
_escAttr from source and asserts apostrophe + quote/backslash/&/<> names
round-trip through HTML+JS decoding, documents that _escAttr throws a
SyntaxError for the apostrophe case while _escJs compiles clean, and pins
wolf39's exact name. pytest shim tests/test_stats_automations_esc_js.py runs
it under node --test (skips if node<22 / absent).
pull/761/head
BoulderBadgeDad 2 weeks ago
parent 22f30d3f40
commit ffb9249ded

@ -0,0 +1,124 @@
// Tests for the inline-onclick string escaping in `webui/static/stats-automations.js`.
//
// Run via:
// node --test tests/static/test_stats_automations_esc.mjs
//
// The pytest wrapper at `tests/test_stats_automations_esc_js.py` shells out to
// `node --test` so this fails the suite if the escaping regresses.
//
// Regression context — the "Road trip-The Rolfe's" delete bug:
// A mirrored playlist whose name contains an apostrophe rendered
// onclick="...deleteMirroredPlaylist(2, 'Road trip-The Rolfe&#39;s')"
// The browser HTML-decodes &#39; back to ' BEFORE the JS parser runs, producing
// deleteMirroredPlaylist(2, 'Road trip-The Rolfe's') // unterminated string
// so the whole handler threw a SyntaxError and never executed. Two visible
// symptoms: (1) event.stopPropagation() never ran, so clicking the ✕ bubbled to
// the card and opened the track preview instead; (2) the preview's "Delete
// Mirror" button silently did nothing (no DELETE request). `_escJs` fixes it by
// backslash-escaping the JS metacharacters first; `_escAttr` (HTML-only) cannot.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC = readFileSync(
resolve(__dirname, '..', '..', 'webui', 'static', 'stats-automations.js'),
'utf8',
);
// Pull a top-level `function NAME(...) { ... }` out of the real source by
// brace-matching and return it as a live function, so the tests exercise the
// shipped implementation rather than a copy.
function extractFn(name) {
const at = SRC.indexOf(`function ${name}(`);
assert.notEqual(at, -1, `function ${name} must exist in stats-automations.js`);
const open = SRC.indexOf('{', at);
let depth = 0;
for (let i = open; i < SRC.length; i++) {
if (SRC[i] === '{') depth++;
else if (SRC[i] === '}' && --depth === 0) {
// eslint-disable-next-line no-eval
return eval(`(${SRC.slice(at, i + 1)})`); // named function expression -> returns the fn
}
}
throw new Error(`could not brace-match function ${name}`);
}
const _escJs = extractFn('_escJs');
const _escAttr = extractFn('_escAttr');
// Reproduce what a browser does to onclick="fn('<value>')" : the HTML parser
// resolves entities, THEN the JS engine parses the resulting source. &amp; is
// resolved last so already-decoded entities aren't double-processed.
function htmlAttrDecode(s) {
return s
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
// Build the exact onclick shape the cards build, decode it like the browser
// would, compile + run it, and capture the argument the handler received.
function invokeOnclick(escFn, name) {
const onclick = `cb(2, '${escFn(name)}')`;
const decoded = htmlAttrDecode(onclick);
let received;
const cb = (_id, n) => { received = n; };
// eslint-disable-next-line no-new-func
new Function('cb', decoded)(cb); // compile + execute the handler like the DOM does
return received;
}
describe('_escJs — onclick string args survive HTML+JS decoding', () => {
test("apostrophe name (the Rolfe's delete bug) round-trips intact", () => {
const name = "Road trip-The Rolfe's";
assert.equal(invokeOnclick(_escJs, name), name);
});
test('quotes, backslashes, ampersands, angle brackets all round-trip', () => {
for (const name of [
"Guns N' Roses",
'He said "hi"',
'back\\slash',
'Tom & Jerry',
'<script>alert(1)</script>',
"O'Brien \\ \"x\" & <y>",
"it's a \"test\" & more",
]) {
assert.equal(
invokeOnclick(_escJs, name), name,
`round-trip failed for ${JSON.stringify(name)}`,
);
}
});
test('plain names are not over-escaped (identical to input)', () => {
assert.equal(_escJs('Classic Rock'), 'Classic Rock');
assert.equal(_escJs('Discover Weekly'), 'Discover Weekly');
});
test('empty / falsy input yields empty string', () => {
assert.equal(_escJs(''), '');
assert.equal(_escJs(null), '');
assert.equal(_escJs(undefined), '');
});
});
describe('regression: _escAttr is unsafe for the onclick JS-string context', () => {
test('apostrophe name compiles to a SyntaxError under _escAttr (the original bug)', () => {
const decoded = htmlAttrDecode(`cb(2, '${_escAttr("Road trip-The Rolfe's")}')`);
// eslint-disable-next-line no-new-func
assert.throws(() => new Function('cb', decoded), SyntaxError);
});
test('_escJs compiles cleanly for that same name', () => {
const decoded = htmlAttrDecode(`cb(2, '${_escJs("Road trip-The Rolfe's")}')`);
// eslint-disable-next-line no-new-func
assert.doesNotThrow(() => new Function('cb', decoded));
});
});

@ -0,0 +1,72 @@
"""Run the JS escaping tests for `webui/static/stats-automations.js` under the
regular pytest sweep.
The actual contract tests live in `tests/static/test_stats_automations_esc.mjs`
and run via Node.js's stable built-in test runner (`node --test`). This shim
shells out to that runner and asserts a clean exit so the JS tests fail the
suite if the inline-onclick escaping (`_escJs`) regresses e.g. a playlist or
automation named with an apostrophe silently breaking its action buttons.
Skipped when:
- `node` isn't on PATH (e.g. Python-only dev container).
- Node version < 22 (the assert-flavor used by the test is 22+).
Run directly:
node --test tests/static/test_stats_automations_esc.mjs
"""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO_ROOT = Path(__file__).resolve().parents[1]
_TEST_FILE = _REPO_ROOT / "tests" / "static" / "test_stats_automations_esc.mjs"
def _node_available() -> bool:
if not shutil.which("node"):
return False
try:
result = subprocess.run(
["node", "--version"],
capture_output=True, text=True, timeout=10,
)
except (subprocess.SubprocessError, FileNotFoundError):
return False
if result.returncode != 0:
return False
raw = (result.stdout or "").strip().lstrip("v")
try:
major = int(raw.split(".")[0])
except (ValueError, IndexError):
return False
return major >= 22
def test_stats_automations_esc_js():
"""Pin the inline-onclick escaping contract via `node --test`."""
if not _node_available():
pytest.skip("Node.js >= 22 required to run the JS escaping tests")
if not _TEST_FILE.exists():
pytest.skip(f"JS test file missing: {_TEST_FILE}")
result = subprocess.run(
["node", "--test", str(_TEST_FILE)],
capture_output=True, text=True,
cwd=str(_REPO_ROOT),
timeout=60,
)
if result.returncode != 0:
pytest.fail(
"JS stats-automations escaping tests failed:\n\n"
f"--- stdout ---\n{result.stdout}\n"
f"--- stderr ---\n{result.stderr}",
pytrace=False,
)

@ -518,10 +518,10 @@ function renderMirroredCard(p, container) {
${phaseHtml}
</div>
</div>
${disc > 0 ? `<button class="mirrored-card-clear" onclick="event.stopPropagation(); clearMirroredDiscovery(${p.id}, '${_escAttr(p.name)}')" title="Clear discovery data">↺</button>` : ''}
<button class="mirrored-card-pipeline" onclick="event.stopPropagation(); runMirroredPlaylistPipeline(${p.id}, '${_escAttr(p.name)}')" title="Refresh, discover, sync, and queue missing tracks">Auto-Sync</button>
<button class="mirrored-card-link" onclick="event.stopPropagation(); editMirroredSourceRef(${p.id}, '${_escAttr(p.name)}', '${_escAttr(p.source)}', '${_escAttr(sourceRef)}')" title="Edit original playlist link">🔗</button>
<button class="mirrored-card-delete" onclick="event.stopPropagation(); deleteMirroredPlaylist(${p.id}, '${_escAttr(p.name)}')" title="Delete mirror"></button>
${disc > 0 ? `<button class="mirrored-card-clear" onclick="event.stopPropagation(); clearMirroredDiscovery(${p.id}, '${_escJs(p.name)}')" title="Clear discovery data">↺</button>` : ''}
<button class="mirrored-card-pipeline" onclick="event.stopPropagation(); runMirroredPlaylistPipeline(${p.id}, '${_escJs(p.name)}')" title="Refresh, discover, sync, and queue missing tracks">Auto-Sync</button>
<button class="mirrored-card-link" onclick="event.stopPropagation(); editMirroredSourceRef(${p.id}, '${_escJs(p.name)}', '${_escJs(p.source)}', '${_escJs(sourceRef)}')" title="Edit original playlist link">🔗</button>
<button class="mirrored-card-delete" onclick="event.stopPropagation(); deleteMirroredPlaylist(${p.id}, '${_escJs(p.name)}')" title="Delete mirror"></button>
`;
card.addEventListener('click', () => {
const st = youtubePlaylistStates[hash];
@ -868,11 +868,11 @@ async function openMirroredPlaylistModal(playlistId) {
</div>
<div class="mirrored-modal-footer">
<div class="mirrored-modal-footer-left">
<button class="mirrored-btn-delete" onclick="closeMirroredModal(); deleteMirroredPlaylist(${playlistId}, '${_escAttr(data.name)}')">Delete Mirror</button>
<button class="mirrored-btn-delete" onclick="closeMirroredModal(); deleteMirroredPlaylist(${playlistId}, '${_escJs(data.name)}')">Delete Mirror</button>
</div>
<div class="mirrored-modal-footer-right" style="display:flex;gap:10px;">
<button class="mirrored-btn-close" onclick="editMirroredSourceRef(${playlistId}, '${_escAttr(data.name)}', '${_escAttr(source)}', '${_escAttr(sourceRef)}')">Edit Source</button>
<button class="mirrored-btn-pipeline" onclick="runMirroredPlaylistPipeline(${playlistId}, '${_escAttr(data.name)}')">Auto-Sync</button>
<button class="mirrored-btn-close" onclick="editMirroredSourceRef(${playlistId}, '${_escJs(data.name)}', '${_escJs(source)}', '${_escJs(sourceRef)}')">Edit Source</button>
<button class="mirrored-btn-pipeline" onclick="runMirroredPlaylistPipeline(${playlistId}, '${_escJs(data.name)}')">Auto-Sync</button>
<button class="mirrored-btn-close" onclick="closeMirroredModal()">Close</button>
<button class="mirrored-btn-discover" onclick="discoverMirroredPlaylist(${playlistId})">Discover</button>
</div>
@ -1176,7 +1176,7 @@ function renderPoolList() {
<span class="pool-track-playlist-badge">${_esc(t.playlist_name)}</span>
</div>
</div>
<button class="playlist-modal-btn playlist-modal-btn-primary pool-fix-btn" onclick="openPoolFixModal(${t.id}, '${_escAttr(t.track_name)}', '${_escAttr(t.artist_name)}')">Fix Match</button>
<button class="playlist-modal-btn playlist-modal-btn-primary pool-fix-btn" onclick="openPoolFixModal(${t.id}, '${_escJs(t.track_name)}', '${_escJs(t.artist_name)}')">Fix Match</button>
</div>
`).join('');
} else {
@ -1218,7 +1218,7 @@ function renderPoolList() {
</div>
<span class="pool-confidence-badge ${confClass}">${conf}%</span>
<span class="pool-use-count">${e.use_count}&times;</span>
<button class="pool-rematch-btn" onclick="rematchPoolCacheEntry(${e.id}, '${_escAttr(e.original_title)}', '${_escAttr(e.original_artist)}')" title="Rematch this track">Rematch</button>
<button class="pool-rematch-btn" onclick="rematchPoolCacheEntry(${e.id}, '${_escJs(e.original_title)}', '${_escJs(e.original_artist)}')" title="Rematch this track">Rematch</button>
<button class="pool-remove-btn" onclick="removePoolCacheEntry(${e.id})" title="Remove cached match">&times;</button>
</div>
`;
@ -2141,13 +2141,13 @@ function _buildAutomationSection(id, label, automations, useGrid, options = {})
const allEnabled = enabledCount === automations.length;
actionsHtml = `
<div class="section-actions" onclick="event.stopPropagation();">
<button class="section-action-btn" title="${allEnabled ? 'Disable all' : 'Enable all'}" onclick="_bulkToggleGroup('${_escAttr(groupName)}', ${allEnabled})">
<button class="section-action-btn" title="${allEnabled ? 'Disable all' : 'Enable all'}" onclick="_bulkToggleGroup('${_escJs(groupName)}', ${allEnabled})">
${allEnabled ? '⏸' : '▶'}
</button>
<button class="section-action-btn" title="Rename group" onclick="_startRenameGroup('${_escAttr(groupName)}', this)">
<button class="section-action-btn" title="Rename group" onclick="_startRenameGroup('${_escJs(groupName)}', this)">
</button>
<button class="section-action-btn section-action-danger" title="Delete group" onclick="_deleteGroup('${_escAttr(groupName)}')">
<button class="section-action-btn section-action-danger" title="Delete group" onclick="_deleteGroup('${_escJs(groupName)}')">
🗑
</button>
</div>
@ -3108,7 +3108,7 @@ function _showGroupDropdown(event, autoId, currentGroup) {
}
allGroups.forEach(g => {
const isActive = g === currentGroup;
html += `<div class="auto-group-option${isActive ? ' active' : ''}" onclick="_assignGroup(${autoId}, '${_escAttr(g)}')">${_esc(g)}</div>`;
html += `<div class="auto-group-option${isActive ? ' active' : ''}" onclick="_assignGroup(${autoId}, '${_escJs(g)}')">${_esc(g)}</div>`;
});
if (allGroups.size) html += '<div class="auto-group-divider"></div>';
html += `<input class="auto-group-input" placeholder="New group name..." onkeydown="if(event.key==='Enter'){_assignGroup(${autoId}, this.value.trim()); event.preventDefault();}">`;
@ -3204,15 +3204,15 @@ function renderAutomationCard(a) {
const _timerTriggers = ['schedule', 'daily_time', 'weekly_time'];
if (a.next_run && a.enabled && _timerTriggers.includes(a.trigger_type)) metaParts.push('<span class="auto-next-run" data-next="' + _escAttr(a.next_run) + '">Next: ' + _autoTimeUntil(a.next_run) + '</span>');
if (!_timerTriggers.includes(a.trigger_type) && a.enabled) metaParts.push('Listening');
if (a.run_count) metaParts.push('<span class="auto-runs-link" onclick="event.stopPropagation(); showAutomationHistory(' + a.id + ', \'' + _escAttr(a.name) + '\', \'' + _escAttr(a.action_type || '') + '\')" title="View run history">Runs: ' + a.run_count + '</span>');
if (a.run_count) metaParts.push('<span class="auto-runs-link" onclick="event.stopPropagation(); showAutomationHistory(' + a.id + ', \'' + _escJs(a.name) + '\', \'' + _escJs(a.action_type || '') + '\')" title="View run history">Runs: ' + a.run_count + '</span>');
if (a.last_error) metaParts.push('Error: ' + _esc(a.last_error));
const dupeBtn = a.is_system ? '' :
`<button class="automation-dupe-btn" title="Duplicate" onclick="event.stopPropagation(); duplicateAutomation(${a.id})">&#128203;</button>`;
const groupBtn = a.is_system ? '' :
`<button class="automation-group-btn${a.group_name ? ' grouped' : ''}" data-group="${_escAttr(a.group_name || '')}" title="${a.group_name ? 'Group: ' + _escAttr(a.group_name) : 'Assign group'}" onclick="event.stopPropagation(); _showGroupDropdown(event, ${a.id}, ${a.group_name ? "'" + _escAttr(a.group_name) + "'" : 'null'})">&#128193;</button>`;
`<button class="automation-group-btn${a.group_name ? ' grouped' : ''}" data-group="${_escAttr(a.group_name || '')}" title="${a.group_name ? 'Group: ' + _escAttr(a.group_name) : 'Assign group'}" onclick="event.stopPropagation(); _showGroupDropdown(event, ${a.id}, ${a.group_name ? "'" + _escJs(a.group_name) + "'" : 'null'})">&#128193;</button>`;
const deleteBtn = a.is_system ? '' :
`<button class="automation-delete-btn" title="Delete" onclick="event.stopPropagation(); deleteAutomation(${a.id}, '${_escAttr(a.name)}')">&#128465;</button>`;
`<button class="automation-delete-btn" title="Delete" onclick="event.stopPropagation(); deleteAutomation(${a.id}, '${_escJs(a.name)}')">&#128465;</button>`;
card.innerHTML = `
<div class="automation-status ${a.enabled ? 'enabled' : 'disabled'}"></div>
@ -4660,6 +4660,29 @@ function _escAttr(str) {
return String(str).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Escape a string for use INSIDE a single-quoted JS string literal that itself
// sits inside a double-quoted HTML attribute, e.g. onclick="fn('<value>')".
// Such a value is decoded twice: first the HTML attribute parser resolves
// entities, then the JS parser reads the string. _escAttr only HTML-escapes, so
// an apostrophe becomes &#39; which the attribute parser turns back into a bare
// ' — terminating the JS string and throwing a SyntaxError that silently kills
// the whole handler (this is the "Road trip-The Rolfe's" delete-button bug).
// Fix: backslash-escape the JS metacharacters (\ and ') FIRST so they survive
// HTML decoding intact, then HTML-escape the characters that would otherwise
// break the surrounding attribute. Order matters — & is escaped before the
// later replacements insert their own entities.
function _escJs(str) {
if (!str) return '';
return String(str)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/\r?\n/g, ' ')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// ===== ENHANCE QUALITY MODAL =====
let _enhanceQualityData = null;

Loading…
Cancel
Save