catalog.ts (14771B)
1 import { filtersFromSearch, filtersToSearch, filterTechniques, countActive, EMPTY_FILTERS, FACET_KEYS, toolRoute, type Technique, type CatalogFilters, type FacetKey } from '../lib/techniques'; 2 import { PAGE, renderRow, countLabel, EMPTY_HTML } from '../lib/render-row'; 3 import { sortTechniques, groupTechniques, contextualCounts, configureCommand, type SortOrder } from '../lib/catalog-workspace'; 4 import { escapeHtml as esc, highlightCommand } from '../lib/highlight'; 5 import { url } from '../lib/url'; 6 import './copy'; 7 8 function readStore<T>(key: string, fallback: T): T { try { return JSON.parse(localStorage.getItem(key) || 'null') ?? fallback; } catch { return fallback; } } 9 10 function init(root: HTMLElement, all: Technique[]) { 11 const $ = <T extends HTMLElement = HTMLElement>(s: string) => root.querySelector<T>(s)!; 12 const list = $('[data-cat-results]'), rail = $('[data-cat-facets]'), panel = $('#catalog-inspector'); 13 const search = $<HTMLInputElement>('[data-cat-search]'), sort = $<HTMLSelectElement>('[data-sort]'), group = $<HTMLInputElement>('[data-group]'); 14 const status = $('[data-workspace-status]'); 15 const byId = new Map(all.map(t => [t.id, t])); 16 let filters: CatalogFilters = filtersFromSearch(location.search); 17 const storedBookmarks = readStore<unknown>('daemonbins:saved', []); 18 const bookmarks = new Set<string>(Array.isArray(storedBookmarks) ? storedBookmarks.filter(id => typeof id === 'string' && byId.has(id)) : []); 19 const storedViews = readStore<unknown>('daemonbins:views', []); 20 let views: { name: string; query: string }[] = Array.isArray(storedViews) ? storedViews.filter(v => v && typeof v.name === 'string' && typeof v.query === 'string').slice(0, 20) : []; 21 let savedOnly = false, selected = '', limit = PAGE; 22 let groups: Technique[][] = []; 23 let opener: HTMLElement | null = null; 24 let debounce: ReturnType<typeof setTimeout>; 25 const save = (key: string, value: unknown) => { try { localStorage.setItem(key, JSON.stringify(value)); return true; } catch { status.textContent = 'Browser storage is unavailable. Changes last for this visit only.'; return false; } }; 26 function readURL() { 27 filters = filtersFromSearch(location.search); 28 const p = new URLSearchParams(location.search); 29 sort.value = ['tool', 'source', 'reviewed'].includes(p.get('sort') || '') ? p.get('sort')! : 'tool'; 30 group.checked = p.get('group') !== '0'; savedOnly = p.get('saved') === '1'; selected = p.get('id') || ''; 31 } 32 function query() { 33 const p = new URLSearchParams(filtersToSearch(filters)); 34 if (sort.value !== 'tool') p.set('sort', sort.value); 35 if (!group.checked) p.set('group', '0'); 36 if (savedOnly) p.set('saved', '1'); 37 if (selected) p.set('id', selected); 38 return p.toString(); 39 } 40 function sync(push: boolean) { 41 const next = location.pathname + (query() ? `?${query()}` : ''); 42 if (next !== location.pathname + location.search) history[push ? 'pushState' : 'replaceState']({}, '', next); 43 } 44 function syncBookmarks() { 45 $('[data-bookmark-count]').textContent = String(bookmarks.size); 46 root.querySelectorAll<HTMLButtonElement>('[data-save]').forEach(b => { 47 const id = b.closest<HTMLElement>('[data-id]')?.dataset.id; 48 const active = !!id && bookmarks.has(id); b.setAttribute('aria-pressed', String(active)); b.textContent = active ? 'Saved' : 'Save'; 49 }); 50 } 51 function updateCounts() { 52 const base = savedOnly ? all.filter(t => bookmarks.has(t.id)) : all; 53 const counts = contextualCounts(base, filters); 54 rail.querySelectorAll<HTMLButtonElement>('[data-facet]').forEach(b => { 55 const k = b.dataset.facet as FacetKey, value = b.dataset.value!; 56 const active = filters[k]?.includes(value) || false; 57 b.setAttribute('aria-checked', String(active)); 58 b.querySelector('.cat-chip__n')!.textContent = String(counts[k][value] || 0); 59 b.disabled = !active && !counts[k][value]; 60 }); 61 } 62 function render(append = false) { 63 const matching = sortTechniques(filterTechniques(savedOnly ? all.filter(t => bookmarks.has(t.id)) : all, filters), sort.value as SortOrder, filters.query); 64 groups = group.checked ? groupTechniques(matching) : matching.map(t => [t]); 65 const start = append ? Math.max(0, limit - PAGE) : 0; 66 const html = groups.slice(start, limit).map(g => renderRow(g[0], g)).join(''); 67 if (append) list.insertAdjacentHTML('beforeend', html); else list.innerHTML = html || EMPTY_HTML; 68 $('[data-cat-count]').innerHTML = countLabel(matching.length, all.length, countActive(filters), filters.addedOnly) + (group.checked ? ` · ${groups.length} tool${groups.length === 1 ? '' : 's'}` : ''); 69 const more = $<HTMLButtonElement>('[data-cat-more]'); more.hidden = groups.length <= limit; 70 more.textContent = `Show ${Math.min(PAGE, Math.max(0, groups.length - limit))} more ${group.checked ? 'tools' : 'techniques'}`; 71 search.value = filters.query; 72 $('[data-cat-new]').setAttribute('aria-pressed', String(filters.addedOnly)); 73 $('[data-bookmarks-only]').setAttribute('aria-pressed', String(savedOnly)); 74 $('[data-active-filters]').innerHTML = FACET_KEYS.flatMap(k => (filters[k] || []).map(v => `<button type="button" data-remove="${k}" data-value="${esc(v)}" aria-label="Remove ${esc(k)} filter ${esc(v)}">${esc(v)} ×</button>`)).join(''); 75 syncBookmarks(); updateCounts(); 76 } 77 const paragraph = (title: string, value?: string) => value ? `<h3>${esc(title)}</h3><p>${esc(value)}</p>` : ''; 78 function inspect(id: string, focus = false) { 79 const t = byId.get(id); selected = t ? id : ''; 80 if (!t) { panel.hidden = true; return; } 81 panel.hidden = false; panel.dataset.id = id; 82 panel.innerHTML = `<div class="inspector__top"><span class="eyebrow">${esc(t.source)}</span><button data-close-inspector type="button" aria-label="Close technique details">Close ×</button></div> 83 <h2 tabindex="-1">${esc(t.toolName)}</h2><p>${esc(t.name || t.capability.join(', '))}</p> 84 <p>${esc(t.verification || 'Upstream reference')}${t.reviewedAt ? ` · reviewed ${esc(t.reviewedAt)}` : ''}</p> 85 ${paragraph('About this command', t.description)} 86 ${t.template ? `<h3>Configure command</h3><p>${t.template.shell === 'posix' ? 'POSIX shell' : 'PowerShell'} quoting. Values stay in this panel and are not saved or added to the URL.</p><form class="command-fields">${t.template.variables.map(v => `<label>${esc(v.label)}<input data-variable="${esc(v.key)}" value="${esc(v.default)}" autocomplete="off" spellcheck="false" /></label>`).join('')}</form><p class="template-error" role="status"></p>` : ''} 87 <div data-cmdbar><pre><code data-preview>${highlightCommand(t.command)}</code></pre><button data-copy type="button">Copy command</button> <button data-save type="button">Save</button></div> 88 ${paragraph('Required access', t.requires?.join(', '))}${paragraph('Execution context', t.context || t.privilege)} 89 ${paragraph('Environment', t.environment?.join(', '))}${paragraph('Services', t.services?.join(', '))} 90 ${paragraph('Compatibility', t.compatibility)}${paragraph('Expected result', t.expected)}${paragraph('Troubleshooting', t.troubleshooting)}${paragraph('Side effects', t.sideEffects)}${paragraph('Restore', t.restore)} 91 ${t.mitre.length ? `<h3>ATT&CK</h3><p>${t.mitre.map(m => `<a href="https://attack.mitre.org/techniques/${esc(m.replace('.', '/'))}/" target="_blank" rel="noopener">${esc(m)}</a>`).join(' · ')}</p>` : ''} 92 ${t.detection?.length ? `<h3>Detection references</h3><ul>${t.detection.map(d => `<li>${esc(d.type)}: ${/^https?:\/\//.test(d.value) ? `<a href="${esc(d.value)}" target="_blank" rel="noopener">Source</a>` : esc(d.value)}</li>`).join('')}</ul>` : ''} 93 <h3>Sources</h3><ul>${t.references.map((r, i) => `<li><a href="${esc(r)}" target="_blank" rel="noopener">${esc(new URL(r).hostname)} · reference ${i + 1}</a></li>`).join('')}</ul><p><a href="${url(toolRoute(t.toolId))}#${esc(t.id)}">Open full tool reference →</a></p>`; 94 panel.querySelector('form')?.addEventListener('submit', e => e.preventDefault()); 95 panel.querySelector('form')?.addEventListener('input', () => { 96 try { 97 const values = Object.fromEntries([...panel.querySelectorAll<HTMLInputElement>('[data-variable]')].map(el => [el.dataset.variable!, el.value])); 98 panel.querySelector('[data-preview]')!.innerHTML = highlightCommand(configureCommand(t.template!, values)); 99 panel.querySelector('.template-error')!.textContent = ''; 100 panel.querySelector<HTMLButtonElement>('[data-copy]')!.disabled = false; 101 } catch (e) { 102 panel.querySelector('.template-error')!.textContent = (e as Error).message; 103 panel.querySelector<HTMLButtonElement>('[data-copy]')!.disabled = true; 104 } 105 }); 106 syncBookmarks(); 107 if (focus) { panel.querySelector<HTMLElement>('h2')!.focus(); if (matchMedia('(max-width:1200px)').matches) panel.scrollIntoView({ block: 'start' }); } 108 } 109 function apply(push = true) { clearTimeout(debounce); limit = PAGE; sync(push); render(); } 110 root.addEventListener('click', e => { 111 const b = (e.target as HTMLElement).closest<HTMLElement>('button'); if (!b) return; 112 if (b.hasAttribute('data-facet') || b.hasAttribute('data-remove')) { 113 const k = (b.dataset.facet || b.dataset.remove) as FacetKey, value = b.dataset.value!; 114 const values = new Set(filters[k] || []); values.has(value) ? values.delete(value) : values.add(value); filters = { ...filters, [k]: [...values] }; apply(); 115 } else if (b.hasAttribute('data-cat-clear')) { filters = { ...EMPTY_FILTERS }; savedOnly = false; selected = ''; inspect(''); apply(); } 116 else if (b.hasAttribute('data-cat-new')) { filters = { ...filters, addedOnly: !filters.addedOnly }; apply(); } 117 else if (b.hasAttribute('data-bookmarks-only')) { savedOnly = !savedOnly; apply(); } 118 else if (b.hasAttribute('data-cat-more')) { limit += PAGE; render(true); } 119 else if (b.hasAttribute('data-filters-toggle')) { const open = root.dataset.filtersOpen !== 'true'; root.dataset.filtersOpen = String(open); b.setAttribute('aria-expanded', String(open)); } 120 else if (b.hasAttribute('data-inspect')) { opener = b; inspect(b.closest<HTMLElement>('[data-id]')!.dataset.id!, true); sync(true); } 121 else if (b.hasAttribute('data-close-inspector')) { inspect(''); sync(true); if (opener?.isConnected) opener.focus(); else search.focus(); } 122 else if (b.hasAttribute('data-save')) { 123 const id = b.closest<HTMLElement>('[data-id]')!.dataset.id!; bookmarks.has(id) ? bookmarks.delete(id) : bookmarks.add(id); 124 save('daemonbins:saved', [...bookmarks]); if (savedOnly) render(); else syncBookmarks(); 125 } 126 }); 127 list.addEventListener('change', e => { 128 const el = e.target as HTMLSelectElement; if (!el.matches('[data-variant]')) return; 129 const t = byId.get(el.value)!; const variants = groups.find(g => g[0].toolId === t.toolId)!; 130 const row = el.closest('.trow')!; row.outerHTML = renderRow(t, variants); syncBookmarks(); 131 [...list.querySelectorAll<HTMLElement>('[data-id]')].find(r => r.dataset.id === t.id)?.querySelector<HTMLSelectElement>('select')?.focus(); 132 if (selected && byId.get(selected)?.toolId === t.toolId) { inspect(t.id); sync(false); } 133 }); 134 search.addEventListener('input', () => { clearTimeout(debounce); debounce = setTimeout(() => { filters = { ...filters, query: search.value }; apply(false); }, 130); }); 135 sort.addEventListener('change', () => apply()); group.addEventListener('change', () => apply()); 136 function viewsMenu() { 137 $<HTMLSelectElement>('[data-views]').innerHTML = '<option value="">Choose a saved view</option>' + views.map((v, i) => `<option value="${i}">${esc(v.name)}</option>`).join(''); 138 $('[data-delete-view]').hidden = true; 139 } 140 $('[data-save-view]').addEventListener('click', () => { 141 const name = window.prompt('Name this view', filters.query || 'My catalog view'); if (!name?.trim()) return; 142 const p = new URLSearchParams(query()); p.delete('id'); 143 views = [...views.filter(v => v.name !== name.trim()), { name: name.trim().slice(0, 80), query: p.toString() }].slice(-20); 144 const persisted = save('daemonbins:views', views); viewsMenu(); if (persisted) status.textContent = 'View saved in this browser.'; 145 }); 146 $('[data-views]').addEventListener('change', e => { 147 const val = (e.target as HTMLSelectElement).value; $('[data-delete-view]').hidden = val === ''; if (val === '') return; 148 history.pushState({}, '', location.pathname + '?' + views[Number(val)].query); readURL(); limit = PAGE; render(); inspect(selected); 149 }); 150 $('[data-delete-view]').addEventListener('click', () => { 151 const index = $<HTMLSelectElement>('[data-views]').value; if (index === '') return; 152 views.splice(Number(index), 1); save('daemonbins:views', views); viewsMenu(); 153 }); 154 const prefs = readStore<Record<string, unknown>>('daemonbins:display', {}); 155 function display() { 156 root.dataset.density = $<HTMLSelectElement>('[data-density]').value; 157 root.dataset.wrap = String($<HTMLInputElement>('[data-wrap]').checked); 158 root.style.setProperty('--command-size', $<HTMLSelectElement>('[data-font]').value + 'px'); 159 document.documentElement.dataset.motion = $<HTMLInputElement>('[data-motion]').checked ? 'reduce' : 'system'; 160 } 161 for (const name of ['density', 'font', 'wrap', 'motion']) { 162 const el = $<HTMLInputElement | HTMLSelectElement>(`[data-${name}]`); 163 if (el instanceof HTMLInputElement) el.checked = prefs[name] === true; 164 else if ([...el.options].some(o => o.value === prefs[name])) el.value = String(prefs[name]); 165 el.addEventListener('change', () => { prefs[name] = el instanceof HTMLInputElement ? el.checked : el.value; save('daemonbins:display', prefs); display(); }); 166 } 167 const onPop = () => { clearTimeout(debounce); readURL(); limit = PAGE; render(); inspect(selected); }; 168 window.addEventListener('popstate', onPop); 169 document.addEventListener('astro:before-swap', () => { clearTimeout(debounce); window.removeEventListener('popstate', onPop); }, { once: true }); 170 readURL(); display(); viewsMenu(); render(); inspect(selected); 171 } 172 173 async function boot() { 174 const root = document.querySelector<HTMLElement>('[data-catalog]'); if (!root || root.dataset.booted) return; 175 root.dataset.booted = '1'; 176 try { 177 const res = await fetch(root.dataset.indexUrl!); if (!res.ok) throw new Error(String(res.status)); 178 const data = await res.json(); if (root.isConnected) init(root, data); 179 } catch (e) { 180 root.querySelector('[data-workspace-status]')!.textContent = 'Interactive catalog could not load. Reload to retry, or follow a tool link for its static reference.'; 181 console.error('[catalog]', e); 182 } 183 } 184 boot(); document.addEventListener('astro:page-load', boot);