diff --git a/AGENTS.md b/AGENTS.md index ebf841e..ecbfba8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ This file provides guidance to AI Agents when working with code in this reposito 4. **Localization (i18n)**: - 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**: - Always update `README.md` and all agent instruction files when making changes. diff --git a/README.md b/README.md index 357434e..f14225e 100644 --- a/README.md +++ b/README.md @@ -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 - 💾 **Persistent Login** — OAuth login saved via cookies - đŸ•šī¸ **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 --- diff --git a/tests/test_frontend_dom_safety.py b/tests/test_frontend_dom_safety.py new file mode 100644 index 0000000..e49f10f --- /dev/null +++ b/tests/test_frontend_dom_safety.py @@ -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 == [] diff --git a/web/static/app.js b/web/static/app.js index ed2e1a5..2ce8b96 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -353,7 +353,9 @@ function renderChannels() { const channels = Object.values(state.channels); if (channels.length === 0) { const emptyMsg = t.gui?.channels?.no_channels || 'No channels tracked yet...'; - container.innerHTML = `

${emptyMsg}

`; + container.replaceChildren( + makeElement('p', { class: 'empty-message' }, emptyMsg), + ); return; } @@ -371,7 +373,9 @@ function renderChannels() { if (filteredChannels.length === 0) { const emptyMsg = t.gui?.channels?.no_channels_for_games || 'No channels found for selected games...'; - container.innerHTML = `

${emptyMsg}

`; + container.replaceChildren( + makeElement('p', { class: 'empty-message' }, emptyMsg), + ); return; } @@ -412,13 +416,6 @@ function renderChannels() { const gameHeader = document.createElement('div'); 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 = `${group.name}`; - } - const channelCount = group.channels.length; 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'); const viewersText = t.gui?.channels?.viewers || 'viewers'; - gameHeader.innerHTML = ` - ${iconHtml} -
-
${group.name}
-
${channelCount} ${channelText} â€ĸ ${totalViewers.toLocaleString()} ${viewersText}
-
- `; + if (group.icon) { + gameHeader.appendChild(makeImageElement(group.icon.replace('{width}', '40').replace('{height}', '53'), group.name, 'game-icon')); + } + gameHeader.appendChild(makeElement('div', { class: 'game-group-info' }, null, el => { + el.appendChild(makeElement('div', { class: 'game-group-name' }, group.name)); + el.appendChild(makeElement('div', { class: 'game-group-stats' }, `${channelCount} ${channelText} â€ĸ ${totalViewers.toLocaleString()} ${viewersText}`)); + })); container.appendChild(gameHeader); @@ -452,17 +449,23 @@ function renderChannels() { if (channel.online) div.classList.add('online'); else div.classList.add('offline'); - let badges = ''; - if (channel.drops_enabled) badges += 'DROPS'; - if (channel.acl_based) badges += 'ACL'; - - div.innerHTML = ` -
${channel.name} ${badges}
-
- ${channel.viewers !== null ? channel.viewers.toLocaleString() + ' viewers' : 'Offline'} - ${channel.watching ? ' â€ĸ WATCHING' : ''} -
- `; + const nameDiv = makeElement('div', { class: 'channel-name' }, channel.name, el => { + if (channel.drops_enabled) { + el.appendChild(document.createTextNode(' ')); + el.appendChild(makeElement('span', { class: 'channel-badge drops' }, 'DROPS')); + } + if (channel.acl_based) { + el.appendChild(document.createTextNode(' ')); + el.appendChild(makeElement('span', { class: 'channel-badge acl' }, 'ACL')); + } + }); + 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); container.appendChild(div); @@ -489,7 +492,10 @@ function updateDropProgress(data) { const dropGameEl = document.getElementById('drop-game'); if (data.campaign_id) { const campaignUrl = `https://www.twitch.tv/drops/campaigns?dropID=${data.campaign_id}`; - dropGameEl.innerHTML = `${data.campaign_name} (${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 { dropGameEl.textContent = `${data.campaign_name} (${data.game_name})`; } @@ -721,7 +727,7 @@ function renderGameDropdown(searchTerm = '') { dropdown.innerHTML = ''; if (filteredGames.length === 0) { - dropdown.innerHTML = ''; + dropdown.replaceChildren(makeElement('div', { class: 'dropdown-item no-results' }, 'No games found')); gameDropdownFocusedIndex = -1; return; } @@ -798,7 +804,7 @@ function updateGameTagsDisplay() { const removeBtn = document.createElement('button'); removeBtn.className = 'game-tag-remove'; - removeBtn.innerHTML = '×'; + removeBtn.textContent = '×'; removeBtn.setAttribute('aria-label', `Remove ${gameName}`); removeBtn.addEventListener('click', (e) => { e.stopPropagation(); @@ -884,12 +890,12 @@ function renderInventory() { if (allCampaigns.length === 0) { const emptyMsg = t.gui?.inventory?.no_campaigns || 'No campaigns loaded yet...'; - container.innerHTML = `

${emptyMsg}

`; + container.replaceChildren(makeElement('p', { class: 'empty-message' }, emptyMsg)); return; } if (campaigns.length === 0) { - container.innerHTML = `

No campaigns match the current filters.

`; + container.replaceChildren(makeElement('p', { class: 'empty-message' }, 'No campaigns match the current filters.')); return; } @@ -911,94 +917,95 @@ function renderInventory() { } 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 => - `
- ${benefit.name} -
- ${benefit.name} - (${benefit.type}) -
-
` - ).join(''); - } - - return ` -
-
-
-
${drop.name}
-
-
-
- ${benefitsHtml} -
-
${drop.current_minutes} / ${drop.required_minutes} minutes (${Math.round(drop.progress * 100)}%)
- ${drop.is_claimed ? `
✓ ${claimedText}
` : ''} -
- `; - }).join(''); - - const campaignNameHtml = `${campaign.name} 🔗` - - // Add LINKED or NOT LINKED badge - const linkStatusBadgeHtml = campaign.linked - ? `LINKED` - : `NOT LINKED`; - - const linkAccountButtonHtml = !campaign.linked && campaign.link_url - ? `` - : ''; - - // 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 = `${campaign.game_name}`; - } - - // 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 = `
${endsLabel.replace('{time}', formattedDate)}
`; - } 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 = `
${startsLabel.replace('{time}', formattedDate)}
`; - } 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 = `
${endsLabel.replace('{time}', formattedDate)}
`; - } - const claimedCountText = t.gui?.inventory?.claimed_drops || 'claimed'; - card.innerHTML = ` -
-
- ${gameIconHtml} - ${campaign.game_name} - ${linkStatusBadgeHtml} -
- ${campaignNameHtml} - ${linkAccountButtonHtml} -
-
- ${statusText} - ${campaign.claimed_drops} / ${campaign.total_drops} ${claimedCountText} -
- ${timingHtml} -
- ${dropsHtml} -
- `; + + // Build drops elements + const dropsEl = makeElement('div', { class: 'campaign-drops' }); + campaign.drops.forEach(drop => { + const dropItem = makeElement('div', { class: `drop-item${drop.is_claimed ? ' claimed' : ''}${drop.can_claim ? ' active' : ''}` }); + dropItem.appendChild( + makeElement('div', { class: 'drop-item-header' }, '', el => + el.appendChild(makeElement('div', { class: 'drop-item-info' }, '', el2 => + el2.appendChild(makeElement('div', {}, '', el3 => + el3.appendChild(makeElement('strong', {}, drop.name)) + )) + )) + ) + ); + const benefitsList = makeElement('div', { class: 'benefits-list' }); + if (drop.benefits && drop.benefits.length > 0) { + drop.benefits.forEach(benefit => { + 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); }); @@ -1181,7 +1188,7 @@ function renderSelectedGames(games) { if (games.length === 0) { const emptyMsg = t.gui?.settings?.no_games_selected || 'No games selected. Check games below to add them.'; - container.innerHTML = `

${emptyMsg}

`; + container.replaceChildren(makeElement('p', { class: 'empty-message' }, emptyMsg)); return; } @@ -1190,12 +1197,12 @@ function renderSelectedGames(games) { div.className = 'sortable-item'; div.draggable = true; div.dataset.game = game; - div.innerHTML = ` - ☰ - ${index + 1} - ${game} - - `; + div.replaceChildren( + makeElement('span', { class: 'drag-handle' }, '☰'), + makeElement('span', { class: 'priority-number' }, String(index + 1)), + makeElement('span', { class: 'game-name' }, game), + makeElement('button', { class: 'remove-btn' }, '✕'), + ); // Event listener for the delete button const removeBtn = div.querySelector('.remove-btn'); @@ -1221,10 +1228,10 @@ function renderAvailableGames(games, filterText) { if (games.length === 0) { if (filterText) { const emptyMsg = t.gui?.settings?.no_games_match || 'No games match your search.'; - container.innerHTML = `

${emptyMsg}

`; + container.replaceChildren(makeElement('p', { class: 'empty-message' }, emptyMsg)); } else { const emptyMsg = t.gui?.settings?.all_games_selected || 'All games are selected or no games available.'; - container.innerHTML = `

${emptyMsg}

`; + container.replaceChildren(makeElement('p', { class: 'empty-message' }, emptyMsg)); } return; } @@ -1232,10 +1239,10 @@ function renderAvailableGames(games, filterText) { games.forEach(game => { const label = document.createElement('label'); label.className = 'game-checkbox'; - label.innerHTML = ` - - ${game} - `; + label.replaceChildren( + makeElement('input', { type: 'checkbox', value: game }), + makeElement('span', {}, game), + ); const checkbox = label.querySelector('input[type="checkbox"]'); checkbox.addEventListener('change', (e) => toggleGameWatch(game, e.target.checked)); @@ -1528,7 +1535,7 @@ async function fetchAndPopulateLanguages() { console.error('Failed to fetch languages:', error); const languageSelect = document.getElementById('language'); if (languageSelect) { - languageSelect.innerHTML = ''; + languageSelect.replaceChildren(makeElement('option', { value: '' }, 'Failed to load languages')); } 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) const helpContent = helpTab.querySelector('.help-content'); if (helpContent) { - // We only need to update the dynamic lists and the github link, preserving the headers we just updated? - // Actually, the previous code was nuke-and-rebuild via innerHTML. - // To keep it consistent with our "ID-based update" philosophy, we should probably avoid nuke-and-rebuild if possible, - // OR just rebuild but include the IDs. - // Since I added IDs to the static HTML, rebuilding via innerHTML will wipe them unless I include them in the template string. - // Strategy: Update the dynamic parts (lists) safely, or just update the whole thing but INCLUDE the IDs. - // Let's update the lists directly if possible, or just re-render properly. + const howToItems = t.gui.help.how_to_use_items || [ + 'Login using your Twitch account (OAuth device code flow)', + 'Link your accounts at twitch.tv/drops/campaigns', + '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' + ]; + 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. - // But they don't have IDs. TO be fully robust I should have added IDs to the lists too. - // But for now, let's stick to the previous innerHTML approach but ensuring IDs are preserved in the template string. - helpContent.innerHTML = ` -

${t.gui.help.about || 'About Twitch Drops Miner'}

-

${t.gui.help.about_text || 'This application automatically mines timed Twitch drops without downloading stream data.'}

- -

${t.gui.help.how_to_use || 'How to Use'}

-
    - ${(t.gui.help.how_to_use_items || [ - 'Login using your Twitch account (OAuth device code flow)', - 'Link your accounts at twitch.tv/drops/campaigns', - '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 => `
  1. ${item}
  2. `).join('')} -
- -

${t.gui.help.features || 'Features'}

- - -

${t.gui.help.important_notes || 'Important Notes'}

- - - - `; + helpContent.replaceChildren( + makeElement('h2', { id: 'help-about-header' }, t.gui.help.about || 'About Twitch Drops Miner'), + makeElement('p', {}, t.gui.help.about_text || 'This application automatically mines timed Twitch drops without downloading stream data.'), + makeElement('h3', { id: 'help-howto-header' }, t.gui.help.how_to_use || 'How to Use'), + makeHelpList('ol', howToItems), + makeElement('h3', { id: 'help-features-header' }, t.gui.help.features || 'Features'), + makeHelpList('ul', featuresItems), + makeElement('h3', { id: 'help-notes-header' }, t.gui.help.important_notes || 'Important Notes'), + makeHelpList('ul', notesItems), + makeElement('div', { class: 'help-links' }, '', el => + el.appendChild(makeElement('a', { href: 'https://github.com/rangermix/TwitchDropsMiner', target: '_blank', rel: 'noopener noreferrer' }, t.gui.help.github_repo || 'GitHub Repository')) + ), + ); } } @@ -2019,7 +2009,7 @@ function renderWantedItems(tree) { if (!tree || tree.length === 0) { const emptyMsg = state.translations.gui?.wanted?.none || 'No wanted drops queued...'; - container.innerHTML = `

${emptyMsg}

`; + container.replaceChildren(makeElement('p', { class: 'empty-message-small' }, emptyMsg)); return; } @@ -2027,57 +2017,44 @@ function renderWantedItems(tree) { const groupEl = document.createElement('div'); groupEl.className = 'wanted-game-group'; - const headerEl = document.createElement('div'); - headerEl.className = 'wanted-game-header'; - // Game Icon let iconUrl = gameGroup.game_icon; 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 - ? `${gameGroup.game_name}` - : ''; + const headerChildren = [makeElement('span', { class: 'wanted-game-index' }, `#${index + 1}`)]; + 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 = ` - #${index + 1} - ${iconHtml} - ${gameGroup.game_name} - `; + const headerEl = makeElement('div', { class: 'wanted-game-header' }, '', el => { + headerChildren.forEach(child => el.appendChild(child)); + }); groupEl.appendChild(headerEl); const campaignListEl = document.createElement('div'); campaignListEl.className = 'wanted-campaign-list'; gameGroup.campaigns.forEach(campaign => { - const cardEl = document.createElement('div'); - cardEl.className = 'wanted-card'; - - cardEl.innerHTML = ` -
- - ${campaign.name} - -
-
-
-
- `; - - const dropContainer = cardEl.querySelector(`#wanted-drops-${campaign.id}`); + const dropContainer = makeElement('div', {}); + const cardEl = makeElement('div', { class: 'wanted-card' }, '', el => { + el.appendChild(makeElement('div', { class: 'wanted-card-header' }, '', h => + h.appendChild(makeElement('a', { href: campaign.url, target: '_blank', rel: 'noopener noreferrer', class: 'wanted-card-campaign-link', title: campaign.name }, campaign.name)) + )); + el.appendChild(makeElement('div', { class: 'wanted-card-body' }, '', b => + b.appendChild(dropContainer) + )); + }); campaign.drops.forEach(drop => { - const dropEl = document.createElement('div'); - dropEl.className = 'wanted-drop-item'; - - let html = `${drop.name}`; - - drop.benefits.forEach(benefit => { - html += `${benefit}`; + const dropEl = makeElement('div', { class: 'wanted-drop-item' }, '', el => { + el.appendChild(makeElement('span', { class: 'wanted-drop-name' }, drop.name)); + drop.benefits.forEach(benefit => { + el.appendChild(makeElement('span', { class: 'wanted-benefit-pill' }, benefit)); + }); }); - - dropEl.innerHTML = html; dropContainer.appendChild(dropEl); }); @@ -2088,3 +2065,72 @@ function renderWantedItems(tree) { container.appendChild(groupEl); }); } + +// ==================== DOM Utilities ==================== + +const TRUSTED_HELP_LINKS = new Set(['https://www.twitch.tv/drops/campaigns']); + +/** + * @param {string} tag + * @param {Record} 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 = /]*\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))); + } +}