fix(frontend): replace innerHTML with DOM API to prevent XSS (#41)

* fix(frontend): replace innerHTML with DOM API to prevent XSS

Replace all non-clearing .innerHTML assignments in app.js with safe DOM
construction using makeElement() and replaceChildren().

* fix frontend DOM rendering regressions

---------

Co-authored-by: Fengqing Liu <fq_aaron@hotmail.com>
This commit is contained in:
Keno Medenbach
2026-04-29 07:06:41 +02:00
committed by GitHub
parent 3ef92d8d7f
commit 081b32d027
4 changed files with 285 additions and 219 deletions

View File

@@ -21,6 +21,7 @@ This file provides guidance to AI Agents when working with code in this reposito
4. **Localization (i18n)**: 4. **Localization (i18n)**:
- Update translation files if there are changes to UI text or console messages. - Update translation files if there are changes to UI text or console messages.
- Frontend translation rendering must use safe DOM construction. Do not inject translated strings with non-clearing `innerHTML`; allowlist any intentional links and build them as DOM nodes.
5. **Documentation**: 5. **Documentation**:
- Always update `README.md` and all agent instruction files when making changes. - Always update `README.md` and all agent instruction files when making changes.

View File

@@ -23,6 +23,7 @@ No more tab juggling, channel switching, or missing rewards — just set it, for
- ⚙️ **Auto Channel Switching** — Always mines the best available stream - ⚙️ **Auto Channel Switching** — Always mines the best available stream
- 💾 **Persistent Login** — OAuth login saved via cookies - 💾 **Persistent Login** — OAuth login saved via cookies
- 🕹️ **Simple Web UI** — Manage everything from your browser - 🕹️ **Simple Web UI** — Manage everything from your browser
- 🛡️ **Safe Frontend Rendering** — Dynamic UI content is rendered with DOM APIs to avoid HTML injection
- 🧩 **Docker-Ready** — One command to deploy anywhere - 🧩 **Docker-Ready** — One command to deploy anywhere
--- ---

View File

@@ -0,0 +1,18 @@
import re
from pathlib import Path
APP_JS = Path(__file__).resolve().parents[1] / "web" / "static" / "app.js"
def test_app_js_only_uses_innerhtml_to_clear_elements():
app_source = APP_JS.read_text(encoding="utf-8")
unsafe_assignments = []
for match in re.finditer(r"\binnerHTML\s*=\s*([^;\n]+)", app_source):
assigned_value = match.group(1).strip()
if assigned_value not in {"''", '""'}:
line_number = app_source.count("\n", 0, match.start()) + 1
unsafe_assignments.append(f"line {line_number}: {match.group(0).strip()}")
assert unsafe_assignments == []

View File

@@ -353,7 +353,9 @@ function renderChannels() {
const channels = Object.values(state.channels); const channels = Object.values(state.channels);
if (channels.length === 0) { if (channels.length === 0) {
const emptyMsg = t.gui?.channels?.no_channels || 'No channels tracked yet...'; const emptyMsg = t.gui?.channels?.no_channels || 'No channels tracked yet...';
container.innerHTML = `<p class="empty-message">${emptyMsg}</p>`; container.replaceChildren(
makeElement('p', { class: 'empty-message' }, emptyMsg),
);
return; return;
} }
@@ -371,7 +373,9 @@ function renderChannels() {
if (filteredChannels.length === 0) { if (filteredChannels.length === 0) {
const emptyMsg = t.gui?.channels?.no_channels_for_games || 'No channels found for selected games...'; const emptyMsg = t.gui?.channels?.no_channels_for_games || 'No channels found for selected games...';
container.innerHTML = `<p class="empty-message">${emptyMsg}</p>`; container.replaceChildren(
makeElement('p', { class: 'empty-message' }, emptyMsg),
);
return; return;
} }
@@ -412,13 +416,6 @@ function renderChannels() {
const gameHeader = document.createElement('div'); const gameHeader = document.createElement('div');
gameHeader.className = 'game-group-header'; gameHeader.className = 'game-group-header';
let iconHtml = '';
if (group.icon) {
// Resize the box art to 40x53 (Twitch's standard small size)
const iconUrl = group.icon.replace('{width}', '40').replace('{height}', '53');
iconHtml = `<img src="${iconUrl}" alt="${group.name}" class="game-icon" onerror="this.style.display='none'">`;
}
const channelCount = group.channels.length; const channelCount = group.channels.length;
const totalViewers = group.channels.reduce((sum, ch) => sum + (ch.viewers || 0), 0); const totalViewers = group.channels.reduce((sum, ch) => sum + (ch.viewers || 0), 0);
@@ -427,13 +424,13 @@ function renderChannels() {
: (t.gui?.channels?.channel_count_plural || 'channels'); : (t.gui?.channels?.channel_count_plural || 'channels');
const viewersText = t.gui?.channels?.viewers || 'viewers'; const viewersText = t.gui?.channels?.viewers || 'viewers';
gameHeader.innerHTML = ` if (group.icon) {
${iconHtml} gameHeader.appendChild(makeImageElement(group.icon.replace('{width}', '40').replace('{height}', '53'), group.name, 'game-icon'));
<div class="game-group-info"> }
<div class="game-group-name">${group.name}</div> gameHeader.appendChild(makeElement('div', { class: 'game-group-info' }, null, el => {
<div class="game-group-stats">${channelCount} ${channelText}${totalViewers.toLocaleString()} ${viewersText}</div> el.appendChild(makeElement('div', { class: 'game-group-name' }, group.name));
</div> el.appendChild(makeElement('div', { class: 'game-group-stats' }, `${channelCount} ${channelText}${totalViewers.toLocaleString()} ${viewersText}`));
`; }));
container.appendChild(gameHeader); container.appendChild(gameHeader);
@@ -452,17 +449,23 @@ function renderChannels() {
if (channel.online) div.classList.add('online'); if (channel.online) div.classList.add('online');
else div.classList.add('offline'); else div.classList.add('offline');
let badges = ''; const nameDiv = makeElement('div', { class: 'channel-name' }, channel.name, el => {
if (channel.drops_enabled) badges += '<span class="channel-badge drops">DROPS</span>'; if (channel.drops_enabled) {
if (channel.acl_based) badges += '<span class="channel-badge acl">ACL</span>'; el.appendChild(document.createTextNode(' '));
el.appendChild(makeElement('span', { class: 'channel-badge drops' }, 'DROPS'));
div.innerHTML = ` }
<div class="channel-name">${channel.name} ${badges}</div> if (channel.acl_based) {
<div class="channel-info"> el.appendChild(document.createTextNode(' '));
${channel.viewers !== null ? channel.viewers.toLocaleString() + ' viewers' : 'Offline'} el.appendChild(makeElement('span', { class: 'channel-badge acl' }, 'ACL'));
${channel.watching ? ' • <strong>WATCHING</strong>' : ''} }
</div> });
`; const infoDiv = makeElement('div', { class: 'channel-info' }, channel.viewers !== null ? channel.viewers.toLocaleString() + ' viewers' : 'Offline', el => {
if (channel.watching) {
el.appendChild(document.createTextNode(' • '));
el.appendChild(makeElement('strong', {}, 'WATCHING'));
}
});
div.replaceChildren(nameDiv, infoDiv);
div.onclick = () => selectChannel(channel.id); div.onclick = () => selectChannel(channel.id);
container.appendChild(div); container.appendChild(div);
@@ -489,7 +492,10 @@ function updateDropProgress(data) {
const dropGameEl = document.getElementById('drop-game'); const dropGameEl = document.getElementById('drop-game');
if (data.campaign_id) { if (data.campaign_id) {
const campaignUrl = `https://www.twitch.tv/drops/campaigns?dropID=${data.campaign_id}`; const campaignUrl = `https://www.twitch.tv/drops/campaigns?dropID=${data.campaign_id}`;
dropGameEl.innerHTML = `<a href="${campaignUrl}" target="_blank" rel="noopener noreferrer" class="drop-campaign-link">${data.campaign_name}</a> (${data.game_name})`; dropGameEl.replaceChildren(
makeElement('a', { href: campaignUrl, target: '_blank', rel: 'noopener noreferrer', class: 'drop-campaign-link' }, data.campaign_name),
document.createTextNode(` (${data.game_name})`),
);
} else { } else {
dropGameEl.textContent = `${data.campaign_name} (${data.game_name})`; dropGameEl.textContent = `${data.campaign_name} (${data.game_name})`;
} }
@@ -721,7 +727,7 @@ function renderGameDropdown(searchTerm = '') {
dropdown.innerHTML = ''; dropdown.innerHTML = '';
if (filteredGames.length === 0) { if (filteredGames.length === 0) {
dropdown.innerHTML = '<div class="dropdown-item no-results">No games found</div>'; dropdown.replaceChildren(makeElement('div', { class: 'dropdown-item no-results' }, 'No games found'));
gameDropdownFocusedIndex = -1; gameDropdownFocusedIndex = -1;
return; return;
} }
@@ -798,7 +804,7 @@ function updateGameTagsDisplay() {
const removeBtn = document.createElement('button'); const removeBtn = document.createElement('button');
removeBtn.className = 'game-tag-remove'; removeBtn.className = 'game-tag-remove';
removeBtn.innerHTML = '×'; removeBtn.textContent = '×';
removeBtn.setAttribute('aria-label', `Remove ${gameName}`); removeBtn.setAttribute('aria-label', `Remove ${gameName}`);
removeBtn.addEventListener('click', (e) => { removeBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -884,12 +890,12 @@ function renderInventory() {
if (allCampaigns.length === 0) { if (allCampaigns.length === 0) {
const emptyMsg = t.gui?.inventory?.no_campaigns || 'No campaigns loaded yet...'; const emptyMsg = t.gui?.inventory?.no_campaigns || 'No campaigns loaded yet...';
container.innerHTML = `<p class="empty-message">${emptyMsg}</p>`; container.replaceChildren(makeElement('p', { class: 'empty-message' }, emptyMsg));
return; return;
} }
if (campaigns.length === 0) { if (campaigns.length === 0) {
container.innerHTML = `<p class="empty-message">No campaigns match the current filters.</p>`; container.replaceChildren(makeElement('p', { class: 'empty-message' }, 'No campaigns match the current filters.'));
return; return;
} }
@@ -911,94 +917,95 @@ function renderInventory() {
} }
const claimedText = t.gui?.inventory?.status?.claimed || 'Claimed'; const claimedText = t.gui?.inventory?.status?.claimed || 'Claimed';
const dropsHtml = campaign.drops.map(drop => {
// Generate HTML for each benefit as its own line
let benefitsHtml = '';
if (drop.benefits && drop.benefits.length > 0) {
benefitsHtml = drop.benefits.map(benefit =>
`<div class="benefit-item">
<img src="${benefit.image_url}" alt="${benefit.name}" class="benefit-icon" onerror="this.style.display='none'">
<div class="benefit-info">
<span class="benefit-name">${benefit.name}</span>
<span class="benefit-type">(${benefit.type})</span>
</div>
</div>`
).join('');
}
return `
<div class="drop-item ${drop.is_claimed ? 'claimed' : ''} ${drop.can_claim ? 'active' : ''}">
<div class="drop-item-header">
<div class="drop-item-info">
<div><strong>${drop.name}</strong></div>
</div>
</div>
<div class="benefits-list">
${benefitsHtml}
</div>
<div>${drop.current_minutes} / ${drop.required_minutes} minutes (${Math.round(drop.progress * 100)}%)</div>
${drop.is_claimed ? `<div>✓ ${claimedText}</div>` : ''}
</div>
`;
}).join('');
const campaignNameHtml = `<a href="${campaign.campaign_url}" target="_blank" rel="noopener noreferrer" class="campaign-name-link">${campaign.name} <span class="external-link-icon">🔗</span></a>`
// Add LINKED or NOT LINKED badge
const linkStatusBadgeHtml = campaign.linked
? `<span class="campaign-badge linked" title="Account is linked">LINKED</span>`
: `<span class="campaign-badge not-linked" onclick="window.open('${campaign.link_url}', '_blank')" title="Click to link your account">NOT LINKED</span>`;
const linkAccountButtonHtml = !campaign.linked && campaign.link_url
? `<button class="link-account-btn" onclick="window.open('${campaign.link_url}', '_blank')">Link Account</button>`
: '';
// Add game icon if available
let gameIconHtml = '';
if (campaign.game_box_art_url) {
const iconUrl = campaign.game_box_art_url.replace('{width}', '52').replace('{height}', '70');
gameIconHtml = `<img src="${iconUrl}" alt="${campaign.game_name}" class="game-icon" onerror="this.style.display='none'">`;
}
// Format campaign timing based on status
let timingHtml = '';
if (campaign.active && campaign.ends_at) {
const endDate = new Date(campaign.ends_at);
const formattedDate = endDate.toLocaleString();
const endsLabel = t.gui?.inventory?.ends || 'Ends: {time}';
timingHtml = `<div class="campaign-timing">${endsLabel.replace('{time}', formattedDate)}</div>`;
} else if (campaign.upcoming && campaign.starts_at) {
const startDate = new Date(campaign.starts_at);
const formattedDate = startDate.toLocaleString();
const startsLabel = t.gui?.inventory?.starts || 'Starts: {time}';
timingHtml = `<div class="campaign-timing">${startsLabel.replace('{time}', formattedDate)}</div>`;
} else if (campaign.expired && campaign.ends_at) {
const endDate = new Date(campaign.ends_at);
const formattedDate = endDate.toLocaleString();
const endsLabel = t.gui?.inventory?.ends || 'Ends: {time}';
timingHtml = `<div class="campaign-timing">${endsLabel.replace('{time}', formattedDate)}</div>`;
}
const claimedCountText = t.gui?.inventory?.claimed_drops || 'claimed'; const claimedCountText = t.gui?.inventory?.claimed_drops || 'claimed';
card.innerHTML = `
<div class="campaign-header"> // Build drops elements
<div class="campaign-game"> const dropsEl = makeElement('div', { class: 'campaign-drops' });
${gameIconHtml} campaign.drops.forEach(drop => {
<span class="campaign-game-name">${campaign.game_name}</span> const dropItem = makeElement('div', { class: `drop-item${drop.is_claimed ? ' claimed' : ''}${drop.can_claim ? ' active' : ''}` });
${linkStatusBadgeHtml} dropItem.appendChild(
</div> makeElement('div', { class: 'drop-item-header' }, '', el =>
${campaignNameHtml} el.appendChild(makeElement('div', { class: 'drop-item-info' }, '', el2 =>
${linkAccountButtonHtml} el2.appendChild(makeElement('div', {}, '', el3 =>
</div> el3.appendChild(makeElement('strong', {}, drop.name))
<div class="campaign-status"> ))
<span>${statusText}</span> ))
<span>${campaign.claimed_drops} / ${campaign.total_drops} ${claimedCountText}</span> )
</div> );
${timingHtml} const benefitsList = makeElement('div', { class: 'benefits-list' });
<div class="campaign-drops"> if (drop.benefits && drop.benefits.length > 0) {
${dropsHtml} drop.benefits.forEach(benefit => {
</div> benefitsList.appendChild(
`; makeElement('div', { class: 'benefit-item' }, '', el => {
el.appendChild(makeImageElement(benefit.image_url, benefit.name, 'benefit-icon'));
el.appendChild(makeElement('div', { class: 'benefit-info' }, '', el2 => {
el2.appendChild(makeElement('span', { class: 'benefit-name' }, benefit.name));
el2.appendChild(makeElement('span', { class: 'benefit-type' }, `(${benefit.type})`));
}));
})
);
});
}
dropItem.appendChild(benefitsList);
dropItem.appendChild(makeElement('div', {}, `${drop.current_minutes} / ${drop.required_minutes} minutes (${Math.round(drop.progress * 100)}%)`));
if (drop.is_claimed) {
dropItem.appendChild(makeElement('div', {}, `${claimedText}`));
}
dropsEl.appendChild(dropItem);
});
// Campaign name link
const campaignNameLink = makeElement('a', { href: campaign.campaign_url, target: '_blank', rel: 'noopener noreferrer', class: 'campaign-name-link' }, campaign.name, el =>
el.appendChild(makeElement('span', { class: 'external-link-icon' }, '🔗'))
);
// Linked/not linked badge
const linkStatusBadge = campaign.linked
? makeElement('span', { class: 'campaign-badge linked', title: 'Account is linked' }, 'LINKED')
: makeElement('span', { class: 'campaign-badge not-linked', title: 'Click to link your account' }, 'NOT LINKED', el => {
el.addEventListener('click', () => window.open(campaign.link_url, '_blank'));
});
// Link account button
const campaignGameDiv = makeElement('div', { class: 'campaign-game' }, '', el => {
if (campaign.game_box_art_url) {
const iconUrl = campaign.game_box_art_url.replace('{width}', '52').replace('{height}', '70');
el.appendChild(makeImageElement(iconUrl, campaign.game_name, 'game-icon'));
}
el.appendChild(makeElement('span', { class: 'campaign-game-name' }, campaign.game_name));
el.appendChild(linkStatusBadge);
});
const campaignHeader = makeElement('div', { class: 'campaign-header' }, '', el => {
el.appendChild(campaignGameDiv);
el.appendChild(campaignNameLink);
if (!campaign.linked && campaign.link_url) {
el.appendChild(makeElement('button', { class: 'link-account-btn' }, 'Link Account', btn => {
btn.addEventListener('click', () => window.open(campaign.link_url, '_blank'));
}));
}
});
const campaignStatus = makeElement('div', { class: 'campaign-status' }, '', el => {
el.appendChild(makeElement('span', {}, statusText));
el.appendChild(makeElement('span', {}, `${campaign.claimed_drops} / ${campaign.total_drops} ${claimedCountText}`));
});
card.replaceChildren(campaignHeader, campaignStatus);
// Campaign timing
if (campaign.active && campaign.ends_at) {
const endsLabel = t.gui?.inventory?.ends || 'Ends: {time}';
card.appendChild(makeElement('div', { class: 'campaign-timing' }, endsLabel.replace('{time}', new Date(campaign.ends_at).toLocaleString())));
} else if (campaign.upcoming && campaign.starts_at) {
const startsLabel = t.gui?.inventory?.starts || 'Starts: {time}';
card.appendChild(makeElement('div', { class: 'campaign-timing' }, startsLabel.replace('{time}', new Date(campaign.starts_at).toLocaleString())));
} else if (campaign.expired && campaign.ends_at) {
const endsLabel = t.gui?.inventory?.ends || 'Ends: {time}';
card.appendChild(makeElement('div', { class: 'campaign-timing' }, endsLabel.replace('{time}', new Date(campaign.ends_at).toLocaleString())));
}
card.appendChild(dropsEl);
container.appendChild(card); container.appendChild(card);
}); });
@@ -1181,7 +1188,7 @@ function renderSelectedGames(games) {
if (games.length === 0) { if (games.length === 0) {
const emptyMsg = t.gui?.settings?.no_games_selected || 'No games selected. Check games below to add them.'; const emptyMsg = t.gui?.settings?.no_games_selected || 'No games selected. Check games below to add them.';
container.innerHTML = `<p class="empty-message">${emptyMsg}</p>`; container.replaceChildren(makeElement('p', { class: 'empty-message' }, emptyMsg));
return; return;
} }
@@ -1190,12 +1197,12 @@ function renderSelectedGames(games) {
div.className = 'sortable-item'; div.className = 'sortable-item';
div.draggable = true; div.draggable = true;
div.dataset.game = game; div.dataset.game = game;
div.innerHTML = ` div.replaceChildren(
<span class="drag-handle">☰</span> makeElement('span', { class: 'drag-handle' }, '☰'),
<span class="priority-number">${index + 1}</span> makeElement('span', { class: 'priority-number' }, String(index + 1)),
<span class="game-name">${game}</span> makeElement('span', { class: 'game-name' }, game),
<button class="remove-btn">✕</button> makeElement('button', { class: 'remove-btn' }, '✕'),
`; );
// Event listener for the delete button // Event listener for the delete button
const removeBtn = div.querySelector('.remove-btn'); const removeBtn = div.querySelector('.remove-btn');
@@ -1221,10 +1228,10 @@ function renderAvailableGames(games, filterText) {
if (games.length === 0) { if (games.length === 0) {
if (filterText) { if (filterText) {
const emptyMsg = t.gui?.settings?.no_games_match || 'No games match your search.'; const emptyMsg = t.gui?.settings?.no_games_match || 'No games match your search.';
container.innerHTML = `<p class="empty-message">${emptyMsg}</p>`; container.replaceChildren(makeElement('p', { class: 'empty-message' }, emptyMsg));
} else { } else {
const emptyMsg = t.gui?.settings?.all_games_selected || 'All games are selected or no games available.'; const emptyMsg = t.gui?.settings?.all_games_selected || 'All games are selected or no games available.';
container.innerHTML = `<p class="empty-message">${emptyMsg}</p>`; container.replaceChildren(makeElement('p', { class: 'empty-message' }, emptyMsg));
} }
return; return;
} }
@@ -1232,10 +1239,10 @@ function renderAvailableGames(games, filterText) {
games.forEach(game => { games.forEach(game => {
const label = document.createElement('label'); const label = document.createElement('label');
label.className = 'game-checkbox'; label.className = 'game-checkbox';
label.innerHTML = ` label.replaceChildren(
<input type="checkbox" value="${game}"> makeElement('input', { type: 'checkbox', value: game }),
<span>${game}</span> makeElement('span', {}, game),
`; );
const checkbox = label.querySelector('input[type="checkbox"]'); const checkbox = label.querySelector('input[type="checkbox"]');
checkbox.addEventListener('change', (e) => toggleGameWatch(game, e.target.checked)); checkbox.addEventListener('change', (e) => toggleGameWatch(game, e.target.checked));
@@ -1528,7 +1535,7 @@ async function fetchAndPopulateLanguages() {
console.error('Failed to fetch languages:', error); console.error('Failed to fetch languages:', error);
const languageSelect = document.getElementById('language'); const languageSelect = document.getElementById('language');
if (languageSelect) { if (languageSelect) {
languageSelect.innerHTML = '<option value="">Failed to load languages</option>'; languageSelect.replaceChildren(makeElement('option', { value: '' }, 'Failed to load languages'));
} }
addConsoleLine('Error: Unable to fetch available languages. Please check your connection or try again later.'); addConsoleLine('Error: Unable to fetch available languages. Please check your connection or try again later.');
} }
@@ -1722,56 +1729,39 @@ function applyTranslations(t) {
// Update list items and links (keeping innerHTML approach for lists as they are dynamic content blocks) // Update list items and links (keeping innerHTML approach for lists as they are dynamic content blocks)
const helpContent = helpTab.querySelector('.help-content'); const helpContent = helpTab.querySelector('.help-content');
if (helpContent) { if (helpContent) {
// We only need to update the dynamic lists and the github link, preserving the headers we just updated? const howToItems = t.gui.help.how_to_use_items || [
// Actually, the previous code was nuke-and-rebuild via innerHTML. 'Login using your Twitch account (OAuth device code flow)',
// To keep it consistent with our "ID-based update" philosophy, we should probably avoid nuke-and-rebuild if possible, 'Link your accounts at <a href="https://www.twitch.tv/drops/campaigns" target="_blank">twitch.tv/drops/campaigns</a>',
// OR just rebuild but include the IDs. 'The miner will automatically discover campaigns and start mining',
// Since I added IDs to the static HTML, rebuilding via innerHTML will wipe them unless I include them in the template string. 'Configure priority games in Settings to focus on what you want',
// Strategy: Update the dynamic parts (lists) safely, or just update the whole thing but INCLUDE the IDs. 'Monitor progress in the Main and Inventory tabs'
// Let's update the lists directly if possible, or just re-render properly. ];
const featuresItems = t.gui.help.features_items || [
'Stream-less drop mining - saves bandwidth',
'Game priority and exclusion lists',
'Tracks up to 199 channels simultaneously',
'Automatic channel switching',
'Real-time progress tracking'
];
const notesItems = t.gui.help.important_notes_items || [
'Do not watch streams on the same account while mining',
'Keep your cookies.jar file secure',
'Requires linked game accounts for drops'
];
// Actually, the best approach for the lists is to find the UL/OL elements. helpContent.replaceChildren(
// But they don't have IDs. TO be fully robust I should have added IDs to the lists too. makeElement('h2', { id: 'help-about-header' }, t.gui.help.about || 'About Twitch Drops Miner'),
// But for now, let's stick to the previous innerHTML approach but ensuring IDs are preserved in the template string. makeElement('p', {}, t.gui.help.about_text || 'This application automatically mines timed Twitch drops without downloading stream data.'),
helpContent.innerHTML = ` makeElement('h3', { id: 'help-howto-header' }, t.gui.help.how_to_use || 'How to Use'),
<h2 id="help-about-header">${t.gui.help.about || 'About Twitch Drops Miner'}</h2> makeHelpList('ol', howToItems),
<p>${t.gui.help.about_text || 'This application automatically mines timed Twitch drops without downloading stream data.'}</p> makeElement('h3', { id: 'help-features-header' }, t.gui.help.features || 'Features'),
makeHelpList('ul', featuresItems),
<h3 id="help-howto-header">${t.gui.help.how_to_use || 'How to Use'}</h3> makeElement('h3', { id: 'help-notes-header' }, t.gui.help.important_notes || 'Important Notes'),
<ol> makeHelpList('ul', notesItems),
${(t.gui.help.how_to_use_items || [ makeElement('div', { class: 'help-links' }, '', el =>
'Login using your Twitch account (OAuth device code flow)', el.appendChild(makeElement('a', { href: 'https://github.com/rangermix/TwitchDropsMiner', target: '_blank', rel: 'noopener noreferrer' }, t.gui.help.github_repo || 'GitHub Repository'))
'Link your accounts at <a href="https://www.twitch.tv/drops/campaigns" target="_blank">twitch.tv/drops/campaigns</a>', ),
'The miner will automatically discover campaigns and start mining', );
'Configure priority games in Settings to focus on what you want',
'Monitor progress in the Main and Inventory tabs'
]).map(item => `<li>${item}</li>`).join('')}
</ol>
<h3 id="help-features-header">${t.gui.help.features || 'Features'}</h3>
<ul>
${(t.gui.help.features_items || [
'Stream-less drop mining - saves bandwidth',
'Game priority and exclusion lists',
'Tracks up to 199 channels simultaneously',
'Automatic channel switching',
'Real-time progress tracking'
]).map(item => `<li>${item}</li>`).join('')}
</ul>
<h3 id="help-notes-header">${t.gui.help.important_notes || 'Important Notes'}</h3>
<ul>
${(t.gui.help.important_notes_items || [
'Do not watch streams on the same account while mining',
'Keep your cookies.jar file secure',
'Requires linked game accounts for drops'
]).map(item => `<li>${item}</li>`).join('')}
</ul>
<div class="help-links">
<a href="https://github.com/rangermix/TwitchDropsMiner" target="_blank">${t.gui.help.github_repo || 'GitHub Repository'}</a>
</div>
`;
} }
} }
@@ -2019,7 +2009,7 @@ function renderWantedItems(tree) {
if (!tree || tree.length === 0) { if (!tree || tree.length === 0) {
const emptyMsg = state.translations.gui?.wanted?.none || 'No wanted drops queued...'; const emptyMsg = state.translations.gui?.wanted?.none || 'No wanted drops queued...';
container.innerHTML = `<p class="empty-message-small">${emptyMsg}</p>`; container.replaceChildren(makeElement('p', { class: 'empty-message-small' }, emptyMsg));
return; return;
} }
@@ -2027,57 +2017,44 @@ function renderWantedItems(tree) {
const groupEl = document.createElement('div'); const groupEl = document.createElement('div');
groupEl.className = 'wanted-game-group'; groupEl.className = 'wanted-game-group';
const headerEl = document.createElement('div');
headerEl.className = 'wanted-game-header';
// Game Icon // Game Icon
let iconUrl = gameGroup.game_icon; let iconUrl = gameGroup.game_icon;
if (iconUrl) { if (iconUrl) {
iconUrl = iconUrl.replace('{width}', '40').replace('{height}', '53'); // 3:4 aspect ratio approx iconUrl = iconUrl.replace('{width}', '40').replace('{height}', '53');
} }
const iconHtml = iconUrl const headerChildren = [makeElement('span', { class: 'wanted-game-index' }, `#${index + 1}`)];
? `<img src="${iconUrl}" alt="${gameGroup.game_name}" class="wanted-game-icon" onerror="this.style.display='none'">` if (iconUrl) {
: ''; headerChildren.push(makeImageElement(iconUrl, gameGroup.game_name, 'wanted-game-icon'));
}
headerChildren.push(makeElement('span', { class: 'wanted-game-title' }, gameGroup.game_name));
headerEl.innerHTML = ` const headerEl = makeElement('div', { class: 'wanted-game-header' }, '', el => {
<span class="wanted-game-index">#${index + 1}</span> headerChildren.forEach(child => el.appendChild(child));
${iconHtml} });
<span class="wanted-game-title">${gameGroup.game_name}</span>
`;
groupEl.appendChild(headerEl); groupEl.appendChild(headerEl);
const campaignListEl = document.createElement('div'); const campaignListEl = document.createElement('div');
campaignListEl.className = 'wanted-campaign-list'; campaignListEl.className = 'wanted-campaign-list';
gameGroup.campaigns.forEach(campaign => { gameGroup.campaigns.forEach(campaign => {
const cardEl = document.createElement('div'); const dropContainer = makeElement('div', {});
cardEl.className = 'wanted-card'; const cardEl = makeElement('div', { class: 'wanted-card' }, '', el => {
el.appendChild(makeElement('div', { class: 'wanted-card-header' }, '', h =>
cardEl.innerHTML = ` h.appendChild(makeElement('a', { href: campaign.url, target: '_blank', rel: 'noopener noreferrer', class: 'wanted-card-campaign-link', title: campaign.name }, campaign.name))
<div class="wanted-card-header"> ));
<a href="${campaign.url}" target="_blank" rel="noopener noreferrer" class="wanted-card-campaign-link" title="${campaign.name}"> el.appendChild(makeElement('div', { class: 'wanted-card-body' }, '', b =>
${campaign.name} b.appendChild(dropContainer)
</a> ));
</div> });
<div class="wanted-card-body">
<div id="wanted-drops-${campaign.id}"></div>
</div>
`;
const dropContainer = cardEl.querySelector(`#wanted-drops-${campaign.id}`);
campaign.drops.forEach(drop => { campaign.drops.forEach(drop => {
const dropEl = document.createElement('div'); const dropEl = makeElement('div', { class: 'wanted-drop-item' }, '', el => {
dropEl.className = 'wanted-drop-item'; el.appendChild(makeElement('span', { class: 'wanted-drop-name' }, drop.name));
drop.benefits.forEach(benefit => {
let html = `<span class="wanted-drop-name">${drop.name}</span>`; el.appendChild(makeElement('span', { class: 'wanted-benefit-pill' }, benefit));
});
drop.benefits.forEach(benefit => {
html += `<span class="wanted-benefit-pill">${benefit}</span>`;
}); });
dropEl.innerHTML = html;
dropContainer.appendChild(dropEl); dropContainer.appendChild(dropEl);
}); });
@@ -2088,3 +2065,72 @@ function renderWantedItems(tree) {
container.appendChild(groupEl); container.appendChild(groupEl);
}); });
} }
// ==================== DOM Utilities ====================
const TRUSTED_HELP_LINKS = new Set(['https://www.twitch.tv/drops/campaigns']);
/**
* @param {string} tag
* @param {Record<string, string|number|boolean>} attrs
* @param {string|number|null} text
* @param {(el: HTMLElement) => void|null} callback
*/
function makeElement(tag, attrs = {}, text = null, callback = null) {
const el = document.createElement(tag);
Object.entries(attrs).forEach(([key, value]) => el.setAttribute(key, String(value)));
if (text !== null && text !== undefined) {
el.textContent = String(text);
}
if (callback) {
callback(el);
}
return el;
}
function makeImageElement(src, alt, className) {
const image = makeElement('img', { src, alt, class: className });
image.onerror = () => {
image.style.display = 'none';
};
return image;
}
function makeHelpList(tag, items) {
return makeElement(tag, {}, null, list => {
items.forEach(item => {
list.appendChild(makeElement('li', {}, null, li => appendTrustedHelpContent(li, item)));
});
});
}
function appendTrustedHelpContent(parent, text) {
const source = String(text);
const linkPattern = /<a\b[^>]*\bhref=(["'])(https:\/\/www\.twitch\.tv\/drops\/campaigns)\1[^>]*>(.*?)<\/a>/gi;
let lastIndex = 0;
let match;
let matched = false;
while ((match = linkPattern.exec(source)) !== null) {
matched = true;
if (match.index > lastIndex) {
parent.appendChild(document.createTextNode(source.slice(lastIndex, match.index)));
}
const href = match[2];
if (TRUSTED_HELP_LINKS.has(href)) {
parent.appendChild(makeElement('a', { href, target: '_blank', rel: 'noopener noreferrer' }, match[3]));
} else {
parent.appendChild(document.createTextNode(match[0]));
}
lastIndex = linkPattern.lastIndex;
}
if (!matched) {
parent.textContent = source;
return;
}
if (lastIndex < source.length) {
parent.appendChild(document.createTextNode(source.slice(lastIndex)));
}
}