From ca43d5ccf7b3a9c5b8eee447ea71e8713e882096 Mon Sep 17 00:00:00 2001 From: everforge Date: Tue, 24 Mar 2026 09:58:59 -0500 Subject: [PATCH] Escape API-driven text before injecting it into tab/panel markup to reduce XSS risk, and add a robust clipboard fallback plus safer tab panel selection for edge-case category names. --- public/app.js | 364 ++++++++++++++++++++++++++++---------------------- 1 file changed, 208 insertions(+), 156 deletions(-) diff --git a/public/app.js b/public/app.js index 62bcb5896..6ae7f41c0 100644 --- a/public/app.js +++ b/public/app.js @@ -1,6 +1,6 @@ import { - initGlassTerminal, - renderTerminalLayout, + initGlassTerminal, + renderTerminalLayout, } from "./js/components/glass-terminal.js"; import { initLensEffect } from "./js/components/lens.js"; import { initFrameworkViz } from "./js/components/framework-viz.js"; @@ -17,40 +17,67 @@ let allCommands = []; // CONTENT LOADING // ============================================ +function escapeHtml(value) { + if (typeof value !== "string") return ""; + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function copyTextFallback(text) { + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.setAttribute("readonly", ""); + textArea.style.position = "absolute"; + textArea.style.left = "-9999px"; + document.body.appendChild(textArea); + textArea.select(); + try { + return document.execCommand("copy"); + } catch { + return false; + } finally { + document.body.removeChild(textArea); + } +} + async function loadContent() { - try { - const [commandsRes, patternsRes] = await Promise.all([ - fetch("/api/commands"), - fetch("/api/patterns"), - ]); + try { + const [commandsRes, patternsRes] = await Promise.all([ + fetch("/api/commands"), + fetch("/api/patterns"), + ]); - // Check for HTTP errors - if (!commandsRes.ok) { - throw new Error(`Commands API failed: ${commandsRes.status}`); - } - if (!patternsRes.ok) { - throw new Error(`Patterns API failed: ${patternsRes.status}`); - } + // Check for HTTP errors + if (!commandsRes.ok) { + throw new Error(`Commands API failed: ${commandsRes.status}`); + } + if (!patternsRes.ok) { + throw new Error(`Patterns API failed: ${patternsRes.status}`); + } - allCommands = await commandsRes.json(); - const patternsData = await patternsRes.json(); + allCommands = await commandsRes.json(); + const patternsData = await patternsRes.json(); - // Render commands (Glass Terminal) - renderTerminalLayout(allCommands); + // Render commands (Glass Terminal) + renderTerminalLayout(allCommands); - // Render patterns with tabbed navigation - renderPatternsWithTabs(patternsData.patterns, patternsData.antipatterns); - } catch (error) { - console.error("Failed to load content:", error); - showLoadError(error); - } + // Render patterns with tabbed navigation + renderPatternsWithTabs(patternsData.patterns, patternsData.antipatterns); + } catch (error) { + console.error("Failed to load content:", error); + showLoadError(error); + } } function showLoadError(error) { - // Show error in commands section - const commandsGallery = document.querySelector('.commands-gallery'); - if (commandsGallery) { - commandsGallery.innerHTML = ` + // Show error in commands section + const commandsGallery = document.querySelector(".commands-gallery"); + if (commandsGallery) { + commandsGallery.innerHTML = ` `; - } + } - // Show error in patterns section - const patternsContainer = document.getElementById("patterns-categories"); - if (patternsContainer) { - patternsContainer.innerHTML = ` + // Show error in patterns section + const patternsContainer = document.getElementById("patterns-categories"); + if (patternsContainer) { + patternsContainer.innerHTML = ` `; - } + } } function renderPatternsWithTabs(patterns, antipatterns) { - const container = document.getElementById("patterns-categories"); - if (!container || !patterns || !antipatterns) return; + const container = document.getElementById("patterns-categories"); + if (!container || !patterns || !antipatterns) return; - // Create a map of antipatterns by category name - const antipatternMap = {}; - antipatterns.forEach(cat => { - antipatternMap[cat.name] = cat.items; - }); + // Create a map of antipatterns by category name + const antipatternMap = {}; + antipatterns.forEach((cat) => { + antipatternMap[cat.name] = cat.items; + }); - // Generate unique IDs for tabs - const tabId = (name) => `pattern-tab-${name.toLowerCase().replace(/\s+/g, '-')}`; - const panelId = (name) => `pattern-panel-${name.toLowerCase().replace(/\s+/g, '-')}`; + // Generate unique IDs for tabs + const tabId = (name) => + `pattern-tab-${name.toLowerCase().replace(/\s+/g, "-")}`; + const panelId = (name) => + `pattern-panel-${name.toLowerCase().replace(/\s+/g, "-")}`; - // Build tabs with WAI-ARIA attributes - const tabsHTML = patterns - .map((category, i) => ``) - .join(""); + id="${tabId(categoryName)}" + aria-selected="${i === 0 ? "true" : "false"}" + aria-controls="${panelId(categoryName)}" + tabindex="${i === 0 ? "0" : "-1"}" + >${escapeHtml(categoryName)}`; + }) + .join(""); - // Build panels with WAI-ARIA attributes - const panelsHTML = patterns - .map((category, i) => { - const antiItems = antipatternMap[category.name] || []; - return ` + // Build panels with WAI-ARIA attributes + const panelsHTML = patterns + .map((category, i) => { + const categoryName = String(category.name ?? ""); + const antiItems = antipatternMap[category.name] || []; + return `
Don't
    - ${antiItems.map((item) => `
  • ${item}
  • `).join("")} + ${antiItems.map((item) => `
  • ${escapeHtml(item)}
  • `).join("")}
Do
    - ${category.items.map((item) => `
  • ${item}
  • `).join("")} + ${category.items.map((item) => `
  • ${escapeHtml(item)}
  • `).join("")}
`; - }) - .join(""); + }) + .join(""); - container.innerHTML = ` + container.innerHTML = `
${tabsHTML}
${panelsHTML}
`; - const tabs = container.querySelectorAll('.pattern-tab'); - const panels = container.querySelectorAll('.pattern-panel'); + const tabs = container.querySelectorAll(".pattern-tab"); + const panels = container.querySelectorAll(".pattern-panel"); - // Function to switch tabs - const switchTab = (newTab) => { - const tabName = newTab.dataset.tab; + // Function to switch tabs + const switchTab = (newTab) => { + const tabName = newTab.dataset.tab; - // Update ARIA attributes on all tabs - tabs.forEach(t => { - t.classList.remove('active'); - t.setAttribute('aria-selected', 'false'); - t.setAttribute('tabindex', '-1'); - }); + // Update ARIA attributes on all tabs + tabs.forEach((t) => { + t.classList.remove("active"); + t.setAttribute("aria-selected", "false"); + t.setAttribute("tabindex", "-1"); + }); - // Activate the new tab - newTab.classList.add('active'); - newTab.setAttribute('aria-selected', 'true'); - newTab.setAttribute('tabindex', '0'); - newTab.focus(); + // Activate the new tab + newTab.classList.add("active"); + newTab.setAttribute("aria-selected", "true"); + newTab.setAttribute("tabindex", "0"); + newTab.focus(); - // Update panels - panels.forEach(p => { - p.classList.remove('active'); - p.setAttribute('hidden', ''); - }); - const activePanel = container.querySelector(`[data-panel="${tabName}"]`); - activePanel.classList.add('active'); - activePanel.removeAttribute('hidden'); - }; + // Update panels + panels.forEach((p) => { + p.classList.remove("active"); + p.setAttribute("hidden", ""); + }); + const escapedTabName = + typeof CSS !== "undefined" && CSS.escape + ? CSS.escape(tabName) + : tabName.replaceAll('"', '\\"'); + const activePanel = container.querySelector( + `[data-panel="${escapedTabName}"]`, + ); + if (!activePanel) return; + activePanel.classList.add("active"); + activePanel.removeAttribute("hidden"); + }; - // Tab click handling - tabs.forEach(tab => { - tab.addEventListener('click', () => switchTab(tab)); - }); + // Tab click handling + tabs.forEach((tab) => { + tab.addEventListener("click", () => switchTab(tab)); + }); - // Keyboard navigation (Arrow keys, Home, End) - tabs.forEach((tab, index) => { - tab.addEventListener('keydown', (e) => { - let targetIndex = index; + // Keyboard navigation (Arrow keys, Home, End) + tabs.forEach((tab, index) => { + tab.addEventListener("keydown", (e) => { + let targetIndex = index; - switch (e.key) { - case 'ArrowLeft': - case 'ArrowUp': - e.preventDefault(); - targetIndex = index === 0 ? tabs.length - 1 : index - 1; - break; - case 'ArrowRight': - case 'ArrowDown': - e.preventDefault(); - targetIndex = index === tabs.length - 1 ? 0 : index + 1; - break; - case 'Home': - e.preventDefault(); - targetIndex = 0; - break; - case 'End': - e.preventDefault(); - targetIndex = tabs.length - 1; - break; - default: - return; - } + switch (e.key) { + case "ArrowLeft": + case "ArrowUp": + e.preventDefault(); + targetIndex = index === 0 ? tabs.length - 1 : index - 1; + break; + case "ArrowRight": + case "ArrowDown": + e.preventDefault(); + targetIndex = index === tabs.length - 1 ? 0 : index + 1; + break; + case "Home": + e.preventDefault(); + targetIndex = 0; + break; + case "End": + e.preventDefault(); + targetIndex = tabs.length - 1; + break; + default: + return; + } - switchTab(tabs[targetIndex]); - }); - }); + switchTab(tabs[targetIndex]); + }); + }); } // ============================================ @@ -216,45 +256,57 @@ function renderPatternsWithTabs(patterns, antipatterns) { // Handle bundle download clicks via event delegation document.addEventListener("click", (e) => { - const bundleBtn = e.target.closest("[data-bundle]"); - if (bundleBtn) { - const provider = bundleBtn.dataset.bundle; - const prefixToggle = document.getElementById('prefix-toggle'); - const usePrefixed = prefixToggle && prefixToggle.checked; - const bundleName = usePrefixed ? `${provider}-prefixed` : provider; - window.location.href = `/api/download/bundle/${bundleName}`; - } + const bundleBtn = e.target.closest("[data-bundle]"); + if (bundleBtn) { + const provider = bundleBtn.dataset.bundle; + const prefixToggle = document.getElementById("prefix-toggle"); + const usePrefixed = prefixToggle && prefixToggle.checked; + const bundleName = usePrefixed ? `${provider}-prefixed` : provider; + window.location.href = `/api/download/bundle/${bundleName}`; + } - // Handle copy button clicks - const copyBtn = e.target.closest("[data-copy]"); - if (copyBtn) { - const textToCopy = copyBtn.dataset.copy; - navigator.clipboard.writeText(textToCopy).then(() => { - copyBtn.classList.add('copied'); - setTimeout(() => copyBtn.classList.remove('copied'), 1500); - }); - } + // Handle copy button clicks + const copyBtn = e.target.closest("[data-copy]"); + if (copyBtn) { + const textToCopy = copyBtn.dataset.copy; + const onCopySuccess = () => { + copyBtn.classList.add("copied"); + setTimeout(() => copyBtn.classList.remove("copied"), 1500); + }; + + if (navigator.clipboard?.writeText) { + navigator.clipboard + .writeText(textToCopy) + .then(onCopySuccess) + .catch(() => { + if (copyTextFallback(textToCopy)) { + onCopySuccess(); + } + }); + } else if (copyTextFallback(textToCopy)) { + onCopySuccess(); + } + } }); - // ============================================ // STARTUP // ============================================ function init() { - initAnchorScroll(); - initHashTracking(); - initLensEffect(); - initScrollReveal(); - initGlassTerminal(); - initFrameworkViz(); - loadContent(); + initAnchorScroll(); + initHashTracking(); + initLensEffect(); + initScrollReveal(); + initGlassTerminal(); + initFrameworkViz(); + loadContent(); - document.body.classList.add("loaded"); + document.body.classList.add("loaded"); } if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", init); + document.addEventListener("DOMContentLoaded", init); } else { - init(); + init(); }