Created
June 21, 2026 20:10
-
-
Save etemiz/a42e9c349bde556d0b27cbdc2ea5663e to your computer and use it in GitHub Desktop.
Example html app that uses Nostr as backend and self upgrades
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>waifu-magnet</title> | |
| <style> | |
| :root { | |
| --bg: #0a0a0b; | |
| --surface: #131316; | |
| --surface2: #1a1a1f; | |
| --border: #26262d; | |
| --border-hi: #3a3a44; | |
| --fg: #e4e4e7; | |
| --muted: #71717a; | |
| --muted2: #a1a1aa; | |
| --accent: #e879f9; | |
| --accent-dim: #a21caf; | |
| --green: #34d399; | |
| --red: #f87171; | |
| --amber: #fbbf24; | |
| --radius: 10px; | |
| --radius-sm: 6px; | |
| --mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace; | |
| --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| html { scroll-behavior: smooth; } | |
| body { | |
| background: var(--bg); color: var(--fg); | |
| font-family: var(--sans); font-size: 14px; line-height: 1.5; | |
| min-height: 100vh; | |
| } | |
| /* === UPDATE BANNER === */ | |
| #banner { | |
| display: none; position: sticky; top: 0; z-index: 100; | |
| background: var(--surface); border-bottom: 1px solid var(--border); | |
| padding: 12px 20px; gap: 14px; align-items: center; | |
| } | |
| #banner.show { display: flex; } | |
| #banner .msg { flex: 1; font-size: 13px; } | |
| #banner .msg strong { font-weight: 600; } | |
| #banner.verified { border-bottom-color: var(--green); } | |
| #banner.verified .msg { color: var(--green); } | |
| #banner.warn { border-bottom-color: var(--red); background: #1a0e0e; } | |
| #banner.warn .msg { color: var(--red); } | |
| #banner .btn { | |
| background: var(--accent); color: #000; padding: 6px 14px; | |
| border-radius: var(--radius-sm); font-weight: 600; font-size: 12px; | |
| text-decoration: none; white-space: nowrap; border: none; cursor: pointer; | |
| font-family: var(--sans); | |
| } | |
| #banner .btn:hover { opacity: 0.88; } | |
| #banner .btn-secondary { | |
| background: transparent; color: var(--fg); border: 1px solid var(--border-hi); | |
| } | |
| #banner .close { | |
| cursor: pointer; color: var(--muted); font-size: 20px; padding: 0 4px; | |
| line-height: 1; background: none; border: none; font-family: var(--sans); | |
| } | |
| #banner .close:hover { color: var(--fg); } | |
| #banner .spinner { | |
| width: 13px; height: 13px; border: 2px solid var(--border); | |
| border-top-color: var(--accent); border-radius: 50%; | |
| animation: spin 0.7s linear infinite; display: inline-block; | |
| vertical-align: middle; margin-right: 6px; | |
| } | |
| /* === HEADER === */ | |
| header { | |
| padding: 28px 24px 20px; border-bottom: 1px solid var(--border); | |
| } | |
| header .top { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; } | |
| header h1 { | |
| font-size: 22px; font-weight: 700; letter-spacing: -0.02em; | |
| background: linear-gradient(135deg, var(--accent), #f0abfc); | |
| -webkit-background-clip: text; -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| header .ver-badge { | |
| font-size: 11px; font-weight: 600; color: var(--accent); | |
| background: rgba(232,121,249,0.1); padding: 2px 8px; | |
| border-radius: 20px; border: 1px solid rgba(232,121,249,0.25); | |
| } | |
| header .stats { | |
| display: flex; gap: 20px; margin-top: 10px; flex-wrap: wrap; | |
| } | |
| header .stat { | |
| font-size: 12px; color: var(--muted); | |
| } | |
| header .stat b { | |
| color: var(--fg); font-weight: 600; font-family: var(--mono); font-size: 13px; | |
| } | |
| header .stat .dot { | |
| display: inline-block; width: 7px; height: 7px; border-radius: 50%; | |
| background: var(--amber); margin-right: 5px; vertical-align: middle; | |
| animation: pulse 1.5s ease-in-out infinite; | |
| } | |
| header .stat .dot.live { background: var(--green); animation: none; } | |
| header .stat .dot.err { background: var(--red); animation: none; } | |
| @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } } | |
| /* === TOOLBAR === */ | |
| #toolbar { | |
| display: flex; gap: 10px; padding: 14px 24px; align-items: center; | |
| border-bottom: 1px solid var(--border); flex-wrap: wrap; | |
| } | |
| #search { | |
| flex: 1; min-width: 200px; background: var(--surface); | |
| border: 1px solid var(--border); color: var(--fg); | |
| padding: 8px 14px 8px 36px; border-radius: var(--radius-sm); | |
| font-size: 13px; font-family: var(--sans); outline: none; | |
| transition: border-color 0.15s; | |
| } | |
| #search:focus { border-color: var(--accent-dim); } | |
| #search-wrap { position: relative; flex: 1; min-width: 200px; } | |
| #search-wrap::before { | |
| content: "⌕"; position: absolute; left: 12px; top: 50%; | |
| transform: translateY(-50%); color: var(--muted); font-size: 16px; | |
| pointer-events: none; | |
| } | |
| #sort { | |
| background: var(--surface); border: 1px solid var(--border); color: var(--fg); | |
| padding: 8px 12px; border-radius: var(--radius-sm); font-size: 12px; | |
| font-family: var(--sans); outline: none; cursor: pointer; | |
| } | |
| #sort:hover { border-color: var(--border-hi); } | |
| /* === CONTENT === */ | |
| #content { padding: 16px 24px 40px; } | |
| #loading { | |
| text-align: center; padding: 60px 20px; color: var(--muted); | |
| } | |
| #loading .spinner { | |
| width: 24px; height: 24px; border: 3px solid var(--border); | |
| border-top-color: var(--accent); border-radius: 50%; | |
| animation: spin 0.7s linear infinite; display: inline-block; margin-bottom: 12px; | |
| } | |
| #loading p { font-size: 13px; } | |
| #empty { | |
| text-align: center; padding: 60px 20px; color: var(--muted); | |
| } | |
| #empty p { font-size: 14px; margin-bottom: 4px; } | |
| #empty .sub { font-size: 12px; color: var(--muted2); } | |
| /* === TORRENT GRID === */ | |
| #grid { | |
| display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); | |
| gap: 12px; | |
| } | |
| .card { | |
| background: var(--surface); border: 1px solid var(--border); | |
| border-radius: var(--radius); padding: 16px; cursor: pointer; | |
| transition: border-color 0.15s, transform 0.1s; position: relative; | |
| overflow: hidden; | |
| } | |
| .card:hover { border-color: var(--border-hi); } | |
| .card:active { transform: scale(0.995); } | |
| .card .name { | |
| font-size: 14px; font-weight: 600; color: var(--fg); | |
| margin-bottom: 8px; word-break: break-all; line-height: 1.35; | |
| padding-right: 20px; | |
| } | |
| .card .meta-row { | |
| display: flex; gap: 10px; align-items: center; flex-wrap: wrap; | |
| margin-bottom: 10px; | |
| } | |
| .card .size { | |
| font-family: var(--mono); font-size: 12px; color: var(--green); | |
| font-weight: 600; | |
| } | |
| .card .source { | |
| font-size: 11px; color: var(--muted2); text-decoration: none; | |
| } | |
| .card .source:hover { color: var(--accent); } | |
| .card .actions { display: flex; gap: 6px; } | |
| .card .act { | |
| background: var(--surface2); border: 1px solid var(--border); color: var(--muted2); | |
| padding: 4px 10px; border-radius: var(--radius-sm); cursor: pointer; | |
| font-size: 11px; font-family: var(--sans); text-decoration: none; | |
| transition: all 0.12s; | |
| } | |
| .card .act:hover { border-color: var(--accent-dim); color: var(--accent); } | |
| .card .act.copied { color: var(--green); border-color: var(--green); } | |
| .card .chevron { | |
| position: absolute; top: 16px; right: 14px; color: var(--muted); | |
| font-size: 14px; transition: transform 0.15s; | |
| } | |
| /* === MODAL === */ | |
| #modal-overlay { | |
| display: none; position: fixed; inset: 0; z-index: 200; | |
| background: rgba(0,0,0,0.7); backdrop-filter: blur(4px); | |
| justify-content: center; align-items: flex-start; padding: 40px 20px; | |
| overflow-y: auto; | |
| } | |
| #modal-overlay.show { display: flex; } | |
| #modal { | |
| background: var(--surface); border: 1px solid var(--border-hi); | |
| border-radius: var(--radius); max-width: 680px; width: 100%; | |
| padding: 24px; position: relative; margin: auto 0; | |
| } | |
| #modal .modal-close { | |
| position: absolute; top: 16px; right: 18px; cursor: pointer; | |
| color: var(--muted); font-size: 22px; line-height: 1; | |
| background: none; border: none; font-family: var(--sans); | |
| } | |
| #modal .modal-close:hover { color: var(--fg); } | |
| #modal .modal-name { | |
| font-size: 17px; font-weight: 700; margin-bottom: 16px; | |
| word-break: break-all; padding-right: 30px; line-height: 1.3; | |
| } | |
| #modal .section { margin-bottom: 16px; } | |
| #modal .label { | |
| font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; | |
| color: var(--muted); margin-bottom: 4px; font-weight: 600; | |
| } | |
| #modal .val { | |
| font-size: 13px; font-family: var(--mono); word-break: break-all; | |
| color: var(--fg); | |
| } | |
| #modal .val.mono-sm { font-size: 11px; color: var(--muted2); } | |
| #modal .pill-row { display: flex; flex-wrap: wrap; gap: 5px; } | |
| #modal .pill { | |
| background: var(--surface2); padding: 3px 8px; border-radius: 4px; | |
| font-size: 11px; color: var(--muted2); font-family: var(--mono); | |
| } | |
| #modal pre { | |
| background: var(--bg); padding: 12px; border-radius: var(--radius-sm); | |
| border: 1px solid var(--border); font-size: 11px; color: var(--muted2); | |
| max-height: 260px; overflow: auto; white-space: pre-wrap; | |
| word-break: break-all; font-family: var(--mono); | |
| } | |
| #modal .copy-inline { | |
| background: var(--surface2); border: 1px solid var(--border); color: var(--muted2); | |
| padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 11px; | |
| font-family: var(--sans); margin-left: 8px; vertical-align: middle; | |
| } | |
| #modal .copy-inline:hover { border-color: var(--accent-dim); color: var(--accent); } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| @media (max-width: 600px) { | |
| header { padding: 20px 16px 14px; } | |
| #toolbar { padding: 12px 16px; } | |
| #content { padding: 12px 16px 32px; } | |
| #grid { grid-template-columns: 1fr; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="banner"> | |
| <span class="msg"></span> | |
| <a class="btn open-link" href="#" target="_blank">Open</a> | |
| <a class="btn btn-secondary dl-link-btn" href="#" download>Download</a> | |
| <button class="close" onclick="document.getElementById('banner').classList.remove('show')">×</button> | |
| </div> | |
| <header> | |
| <div class="top"> | |
| <h1>waifu-magnet</h1> | |
| <span class="ver-badge">v<span id="ver">7</span></span> | |
| </div> | |
| <div class="stats"> | |
| <span class="stat"><span class="dot" id="conn-dot"></span><b id="relay-count">0</b>/<span id="relay-total">0</span> relays</span> | |
| <span class="stat"><b id="torrent-count">0</b> torrents</span> | |
| <span class="stat"><b id="total-size">0 B</b> total</span> | |
| </div> | |
| </header> | |
| <div id="toolbar"> | |
| <div id="search-wrap"> | |
| <input type="text" id="search" placeholder="Filter by name, hash, source…" autocomplete="off"> | |
| </div> | |
| <select id="sort"> | |
| <option value="newest">Newest first</option> | |
| <option value="oldest">Oldest first</option> | |
| <option value="largest">Largest first</option> | |
| <option value="smallest">Smallest first</option> | |
| <option value="name">Name (A–Z)</option> | |
| </select> | |
| </div> | |
| <div id="content"> | |
| <div id="loading"> | |
| <div class="spinner"></div> | |
| <p id="loading-text">connecting to relays…</p> | |
| </div> | |
| <div id="empty" style="display:none"> | |
| <p>No torrents found.</p> | |
| <p class="sub">Waiting for events from whitelisted npubs on kind 30099.</p> | |
| </div> | |
| <div id="grid"></div> | |
| </div> | |
| <div id="modal-overlay"> | |
| <div id="modal"> | |
| <button class="modal-close" onclick="closeModal()">×</button> | |
| <div class="modal-name" id="modal-name"></div> | |
| <div id="modal-body"></div> | |
| </div> | |
| </div> | |
| <script> | |
| "use strict"; | |
| // === CONFIG === | |
| const VERSION = "7"; | |
| const NPUBS = ["npub1zyal4wt4f86rlgxwjzkmdmcr70ejee66z7crfmy5dpxyentzc0zsspedcr"]; | |
| const RELAYS = [ | |
| "wss://nos.lol/", | |
| "wss://nostr-01.yakihonne.com/", | |
| "wss://nostr.mom/", | |
| "wss://relay.damus.io/", | |
| "wss://relay.primal.net/", | |
| "wss://relay.snort.social", | |
| "wss://relay.mostr.pub", | |
| "wss://no.str.cr", | |
| "wss://offchain.pub", | |
| ]; | |
| const TORRENT_KIND = 30099; | |
| const CLIENT_KIND = 30100; | |
| // === BECH32 DECODER (npub -> hex) === | |
| const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; | |
| function bech32Decode(s) { | |
| s = s.toLowerCase(); | |
| const pos = s.lastIndexOf("1"); | |
| if (pos < 1 || pos + 7 > s.length) return null; | |
| const hrp = s.slice(0, pos); | |
| const dataPart = s.slice(pos + 1); | |
| let values = []; | |
| for (const c of dataPart) { | |
| const idx = BECH32_CHARSET.indexOf(c); | |
| if (idx < 0) return null; | |
| values.push(idx); | |
| } | |
| const chkHrp = hrpExpand(hrp); | |
| const allData = chkHrp.concat(values); | |
| if (!verifyChecksum(allData)) return null; | |
| const dataBytes = values.slice(0, values.length - 6); | |
| const decoded = convertBits(dataBytes, 5, 8, false); | |
| if (!decoded) return null; | |
| return decoded.map(b => b.toString(16).padStart(2, "0")).join(""); | |
| } | |
| function hrpExpand(hrp) { | |
| let ret = []; | |
| for (const c of hrp) ret.push(c.charCodeAt(0) >> 5); | |
| ret.push(0); | |
| for (const c of hrp) ret.push(c.charCodeAt(0) & 31); | |
| return ret; | |
| } | |
| function polymod(values) { | |
| const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; | |
| let chk = 1; | |
| for (const v of values) { | |
| const b = chk >> 25; | |
| chk = ((chk & 0x1ffffff) << 5) ^ v; | |
| for (let i = 0; i < 5; i++) { | |
| if (((b >> i) & 1)) chk ^= GEN[i]; | |
| } | |
| } | |
| return chk; | |
| } | |
| function verifyChecksum(data) { return polymod(data) === 1; } | |
| function convertBits(data, fromBits, toBits, pad) { | |
| let acc = 0, bits = 0, ret = []; | |
| const maxv = (1 << toBits) - 1; | |
| const maxAcc = (1 << (fromBits + toBits - 1)) - 1; | |
| for (const v of data) { | |
| if (v < 0 || (v >> fromBits) !== 0) return null; | |
| acc = ((acc << fromBits) | v) & maxAcc; | |
| bits += fromBits; | |
| while (bits >= toBits) { | |
| bits -= toBits; | |
| ret.push((acc >> bits) & maxv); | |
| } | |
| } | |
| if (pad) { | |
| if (bits) ret.push((acc << (toBits - bits)) & maxv); | |
| } else if (bits >= fromBits || ((acc << (toBits - bits)) & maxv)) { | |
| return null; | |
| } | |
| return ret; | |
| } | |
| // === HELPERS === | |
| function humanSize(bytes) { | |
| const b = parseInt(bytes) || 0; | |
| if (b < 1024) return b + " B"; | |
| if (b < 1048576) return (b / 1024).toFixed(1) + " KiB"; | |
| if (b < 1073741824) return (b / 1048576).toFixed(1) + " MiB"; | |
| return (b / 1073741824).toFixed(2) + " GiB"; | |
| } | |
| function shortHash(hex, n) { return hex ? hex.slice(0, n) + "…" : ""; } | |
| function hostname(url) { | |
| try { return new URL(url).hostname.replace(/^www\./, ""); } | |
| catch { return url; } | |
| } | |
| function tagVal(tags, name) { | |
| for (const t of tags) { if (t[0] === name) return t[1]; } | |
| return null; | |
| } | |
| function tagVals(tags, name) { | |
| return tags.filter(t => t[0] === name).map(t => t[1]); | |
| } | |
| function escapeHtml(s) { | |
| return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""); | |
| } | |
| // === STATE === | |
| let torrentMap = new Map(); | |
| let relaysConnected = 0; | |
| let relaysEOSE = 0; | |
| let relayErrors = 0; | |
| let searchQuery = ""; | |
| let sortMode = "newest"; | |
| document.getElementById("relay-total").textContent = RELAYS.length; | |
| // === RENDER === | |
| function getFiltered() { | |
| let arr = [...torrentMap.values()]; | |
| if (searchQuery) { | |
| const q = searchQuery.toLowerCase(); | |
| arr = arr.filter(e => { | |
| const t = e.event.tags; | |
| const name = (tagVal(t, "name") || "").toLowerCase(); | |
| const ih = (tagVal(t, "d") || "").toLowerCase(); | |
| const src = (tagVal(t, "source") || "").toLowerCase(); | |
| return name.includes(q) || ih.includes(q) || src.includes(q); | |
| }); | |
| } | |
| switch (sortMode) { | |
| case "oldest": arr.sort((a,b) => a.created_at - b.created_at); break; | |
| case "largest": arr.sort((a,b) => parseInt(tagVal(b.event.tags,"size")||0) - parseInt(tagVal(a.event.tags,"size")||0)); break; | |
| case "smallest":arr.sort((a,b) => parseInt(tagVal(a.event.tags,"size")||0) - parseInt(tagVal(b.event.tags,"size")||0)); break; | |
| case "name": arr.sort((a,b) => (tagVal(a.event.tags,"name")||"").localeCompare(tagVal(b.event.tags,"name")||"")); break; | |
| default: arr.sort((a,b) => b.created_at - a.created_at); | |
| } | |
| return arr; | |
| } | |
| function renderGrid() { | |
| const grid = document.getElementById("grid"); | |
| const filtered = getFiltered(); | |
| document.getElementById("torrent-count").textContent = torrentMap.size; | |
| let total = 0; | |
| for (const e of torrentMap.values()) total += parseInt(tagVal(e.event.tags,"size")||0); | |
| document.getElementById("total-size").textContent = humanSize(total); | |
| if (torrentMap.size === 0) { | |
| grid.innerHTML = ""; | |
| return; | |
| } | |
| grid.innerHTML = ""; | |
| for (const entry of filtered) { | |
| const ev = entry.event; | |
| const tags = ev.tags; | |
| const ih = tagVal(tags, "d") || ""; | |
| const name = tagVal(tags, "name") || ih.slice(0, 12); | |
| const size = tagVal(tags, "size") || "0"; | |
| const source = tagVal(tags, "source") || ""; | |
| const magnet = tagVal(tags, "magnet") || ""; | |
| const urls = tagVals(tags, "url"); | |
| const card = document.createElement("div"); | |
| card.className = "card"; | |
| card.onclick = function(e) { | |
| if (e.target.classList.contains("act")) return; | |
| openModal(entry); | |
| }; | |
| card.innerHTML = | |
| '<div class="name">' + escapeHtml(name) + '</div>' + | |
| '<span class="chevron">›</span>' + | |
| '<div class="meta-row">' + | |
| '<span class="size">' + humanSize(size) + '</span>' + | |
| (source ? '<a class="source" href="https://' + escapeHtml(source) + '" target="_blank" onclick="event.stopPropagation()">' + escapeHtml(source) + '</a>' : '') + | |
| '</div>' + | |
| '<div class="actions">' + | |
| '<span class="act" onclick="event.stopPropagation();copyText(\'' + escapeHtml(magnet) + '\',this)">magnet</span>' + | |
| (urls.length ? '<a class="act" href="' + escapeHtml(urls[0]) + '" download onclick="event.stopPropagation()">.torrent</a>' : '') + | |
| '</div>'; | |
| grid.appendChild(card); | |
| } | |
| } | |
| function updateConnStatus() { | |
| const dot = document.getElementById("conn-dot"); | |
| const total = RELAYS.length; | |
| const done = relaysEOSE + relayErrors; | |
| document.getElementById("relay-count").textContent = relaysConnected; | |
| if (done >= total) { | |
| dot.classList.add(relaysConnected > 0 ? "live" : "err"); | |
| dot.classList.remove("err"); | |
| if (relaysConnected > 0) dot.classList.add("live"); | |
| const loading = document.getElementById("loading"); | |
| if (torrentMap.size === 0) { | |
| loading.style.display = "none"; | |
| document.getElementById("empty").style.display = "block"; | |
| } else { | |
| loading.style.display = "none"; | |
| } | |
| } | |
| } | |
| function copyText(text, btn) { | |
| navigator.clipboard.writeText(text).then(() => { | |
| const orig = btn.textContent; | |
| btn.textContent = "copied!"; | |
| btn.classList.add("copied"); | |
| setTimeout(() => { btn.textContent = orig; btn.classList.remove("copied"); }, 1200); | |
| }); | |
| } | |
| window.copyText = copyText; | |
| // === MODAL === | |
| function openModal(entry) { | |
| const ev = entry.event; | |
| const tags = ev.tags; | |
| const ih = tagVal(tags, "d") || ""; | |
| const name = tagVal(tags, "name") || ih.slice(0, 12); | |
| const size = tagVal(tags, "size") || "0"; | |
| const source = tagVal(tags, "source") || ""; | |
| const magnet = tagVal(tags, "magnet") || ""; | |
| const urls = tagVals(tags, "url"); | |
| const webseeds = tagVals(tags, "webseed"); | |
| const trackers = tagVals(tags, "tracker"); | |
| const pieces = tagVal(tags, "pieces") || ""; | |
| const pieceLen = tagVal(tags, "piece_length") || ""; | |
| document.getElementById("modal-name").textContent = name; | |
| let html = ""; | |
| html += '<div class="section"><div class="label">Infohash</div><div class="val">' + escapeHtml(ih) + | |
| ' <span class="copy-inline" onclick="copyText(\'' + ih + '\',this)">copy</span></div></div>'; | |
| html += '<div class="section"><div class="label">Size</div><div class="val">' + humanSize(size) + '</div></div>'; | |
| if (source) html += '<div class="section"><div class="label">Source</div><div class="val"><a href="https://' + escapeHtml(source) + '" target="_blank" style="color:var(--accent)">' + escapeHtml(source) + '</a></div></div>'; | |
| html += '<div class="section"><div class="label">Magnet URI</div><div class="val">' + escapeHtml(magnet) + | |
| ' <span class="copy-inline" onclick="copyText(\'' + escapeHtml(magnet) + '\',this)">copy</span></div></div>'; | |
| if (pieces || pieceLen) | |
| html += '<div class="section"><div class="label">Pieces</div><div class="val">' + escapeHtml(pieces) + ' pieces · ' + humanSize(pieceLen) + ' each</div></div>'; | |
| if (urls.length) { | |
| html += '<div class="section"><div class="label">.torrent files (' + urls.length + ')</div><div class="pill-row">'; | |
| for (const u of urls) html += '<a class="pill" href="' + escapeHtml(u) + '" download>' + escapeHtml(hostname(u)) + '</a>'; | |
| html += '</div></div>'; | |
| } | |
| if (webseeds.length) { | |
| html += '<div class="section"><div class="label">Webseeds (' + webseeds.length + ')</div><div class="pill-row">'; | |
| for (const ws of webseeds) html += '<span class="pill">' + escapeHtml(ws) + '</span>'; | |
| html += '</div></div>'; | |
| } | |
| if (trackers.length) { | |
| html += '<div class="section"><div class="label">Trackers (' + trackers.length + ')</div><div class="pill-row">'; | |
| for (const tr of trackers) html += '<span class="pill">' + escapeHtml(tr) + '</span>'; | |
| html += '</div></div>'; | |
| } | |
| html += '<div class="section"><div class="label">Raw Nostr Event</div><pre>' + escapeHtml(JSON.stringify(ev, null, 2)) + '</pre></div>'; | |
| document.getElementById("modal-body").innerHTML = html; | |
| document.getElementById("modal-overlay").classList.add("show"); | |
| } | |
| function closeModal() { | |
| document.getElementById("modal-overlay").classList.remove("show"); | |
| } | |
| window.closeModal = closeModal; | |
| document.getElementById("modal-overlay").addEventListener("click", function(e) { | |
| if (e.target === this) closeModal(); | |
| }); | |
| document.addEventListener("keydown", function(e) { | |
| if (e.key === "Escape") closeModal(); | |
| }); | |
| // === SEARCH + SORT === | |
| document.getElementById("search").addEventListener("input", function(e) { | |
| searchQuery = e.target.value; | |
| renderGrid(); | |
| }); | |
| document.getElementById("sort").addEventListener("change", function(e) { | |
| sortMode = e.target.value; | |
| renderGrid(); | |
| }); | |
| // === SELF-UPDATE CHECK (SHA-256 verified, TOCTOU-safe via blob URL) === | |
| let bestNewVersion = null; | |
| let bestNewEvent = null; | |
| let bestVerifiedBlobUrl = null; | |
| function checkUpdate(ev) { | |
| const tags = ev.tags; | |
| const v = tagVal(tags, "version"); | |
| if (!v) return; | |
| const vNum = parseInt(v); | |
| const curNum = parseInt(VERSION); | |
| if (isNaN(vNum) || vNum <= curNum) return; | |
| const urls = tagVals(tags, "url"); | |
| if (!urls.length) return; | |
| const sha = tagVal(tags, "sha256"); | |
| if (!sha) { console.warn("[update] no sha256 tag; ignoring"); return; } | |
| if (bestNewVersion === null || vNum > bestNewVersion) { | |
| if (bestVerifiedBlobUrl) { URL.revokeObjectURL(bestVerifiedBlobUrl); bestVerifiedBlobUrl = null; } | |
| bestNewVersion = vNum; | |
| bestNewEvent = ev; | |
| showBannerVerifying(vNum); | |
| verifyUpdate(ev); | |
| } | |
| } | |
| function showBannerVerifying(v) { | |
| const b = document.getElementById("banner"); | |
| b.classList.add("show"); b.classList.remove("warn", "verified"); | |
| b.querySelector(".msg").innerHTML = '<span class="spinner"></span>Version ' + v + | |
| ' available — verifying SHA-256 integrity…'; | |
| b.querySelector(".open-link").style.display = "none"; | |
| b.querySelector(".dl-link-btn").style.display = "none"; | |
| } | |
| function showBannerVerified(v, blobUrl, shaHex) { | |
| if (bestNewEvent && tagVal(bestNewEvent.tags, "version") !== String(v)) return; | |
| bestVerifiedBlobUrl = blobUrl; | |
| const b = document.getElementById("banner"); | |
| b.classList.add("show", "verified"); b.classList.remove("warn"); | |
| b.querySelector(".msg").innerHTML = '<strong>Update ' + v + ' ready</strong> — SHA-256 verified ' + | |
| '<span style="opacity:0.6">' + shortHash(shaHex, 12) + '</span> ' + | |
| '<span style="opacity:0.5;font-size:11px">(local verified copy)</span>'; | |
| const open = b.querySelector(".open-link"); | |
| open.style.display = ""; open.href = blobUrl; | |
| const dl = b.querySelector(".dl-link-btn"); | |
| dl.style.display = ""; dl.href = blobUrl; | |
| dl.setAttribute("download", "waifu-magnet-" + v + ".html"); | |
| } | |
| function showBannerWarn(v, msg) { | |
| if (bestNewEvent && tagVal(bestNewEvent.tags, "version") !== String(v)) return; | |
| if (bestVerifiedBlobUrl) { URL.revokeObjectURL(bestVerifiedBlobUrl); bestVerifiedBlobUrl = null; } | |
| const b = document.getElementById("banner"); | |
| b.classList.add("show", "warn"); b.classList.remove("verified"); | |
| b.querySelector(".msg").innerHTML = '<strong>WARNING:</strong> ' + msg + | |
| ' — possible Blossom tampering, do NOT upgrade.'; | |
| b.querySelector(".open-link").style.display = "none"; | |
| b.querySelector(".dl-link-btn").style.display = "none"; | |
| } | |
| async function verifyUpdate(ev) { | |
| const v = tagVal(ev.tags, "version"); | |
| const expectedSha = tagVal(ev.tags, "sha256").toLowerCase(); | |
| const urls = tagVals(ev.tags, "url"); | |
| const expectedSize = tagVal(ev.tags, "size"); | |
| let verifiedBytes = null; | |
| let verifiedSha = null; | |
| const mismatches = []; | |
| const errors = []; | |
| for (const url of urls) { | |
| let buf; | |
| try { | |
| const resp = await fetch(url, { cache: "no-store" }); | |
| if (!resp.ok) { errors.push(hostname(url) + " HTTP " + resp.status); continue; } | |
| buf = await resp.arrayBuffer(); | |
| } catch (e) { | |
| errors.push(hostname(url) + " " + (e.message || "fetch failed")); | |
| continue; | |
| } | |
| if (expectedSize && buf.byteLength !== parseInt(expectedSize)) { | |
| mismatches.push(hostname(url) + " size " + buf.byteLength + "!=" + expectedSize); | |
| continue; | |
| } | |
| let hashHex; | |
| try { | |
| const hashBuf = await crypto.subtle.digest("SHA-256", buf); | |
| hashHex = [...new Uint8Array(hashBuf)].map(b => b.toString(16).padStart(2, "0")).join(""); | |
| } catch (e) { | |
| errors.push(hostname(url) + " hash failed"); continue; | |
| } | |
| if (hashHex === expectedSha) { verifiedBytes = buf; verifiedSha = hashHex; break; } | |
| else { mismatches.push(hostname(url) + " " + shortHash(hashHex, 8)); } | |
| } | |
| if (verifiedBytes !== null) { | |
| const blob = new Blob([verifiedBytes], { type: "text/html" }); | |
| const blobUrl = URL.createObjectURL(blob); | |
| showBannerVerified(parseInt(v), blobUrl, verifiedSha); | |
| } else if (mismatches.length) { | |
| showBannerWarn(parseInt(v), "Hash mismatch: " + mismatches.join("; ")); | |
| } else { | |
| showBannerWarn(parseInt(v), "Could not verify: " + errors.join("; ")); | |
| } | |
| } | |
| // === RELAY CONNECTION === | |
| const authorHexes = NPUBS.map(bech32Decode).filter(h => h); | |
| if (!authorHexes.length) { | |
| document.getElementById("loading-text").textContent = "error: could not decode npub"; | |
| } else { | |
| for (const relayUrl of RELAYS) connectRelay(relayUrl, authorHexes); | |
| } | |
| function connectRelay(url, authors) { | |
| let ws; | |
| try { ws = new WebSocket(url); } | |
| catch (e) { relayErrors++; updateConnStatus(); return; } | |
| const subIdT = "t" + Math.random().toString(36).slice(2, 8); | |
| const subIdC = "c" + Math.random().toString(36).slice(2, 8); | |
| let gotEOSE = false; | |
| ws.onopen = function() { | |
| relaysConnected++; | |
| ws.send(JSON.stringify(["REQ", subIdT, { kinds: [TORRENT_KIND], authors: authors, limit: 500 }])); | |
| ws.send(JSON.stringify(["REQ", subIdC, { kinds: [CLIENT_KIND], authors: authors, limit: 1 }])); | |
| updateConnStatus(); | |
| }; | |
| ws.onmessage = function(msg) { | |
| let data; | |
| try { data = JSON.parse(msg.data); } catch { return; } | |
| if (data[0] === "EVENT") { | |
| const ev = data[2]; | |
| if (ev.kind === TORRENT_KIND) { | |
| const ih = tagVal(ev.tags, "d"); | |
| if (!ih) return; | |
| const existing = torrentMap.get(ih); | |
| if (!existing || ev.created_at > existing.created_at) { | |
| torrentMap.set(ih, { event: ev, created_at: ev.created_at }); | |
| renderGrid(); | |
| } | |
| } else if (ev.kind === CLIENT_KIND) { | |
| checkUpdate(ev); | |
| } | |
| } else if (data[0] === "EOSE") { | |
| if (!gotEOSE) { gotEOSE = true; relaysEOSE++; updateConnStatus(); } | |
| } | |
| }; | |
| ws.onerror = function() { if (!gotEOSE) { relayErrors++; updateConnStatus(); } }; | |
| ws.onclose = function() { if (!gotEOSE) { relayErrors++; updateConnStatus(); } }; | |
| } | |
| setTimeout(function() { | |
| if (relaysEOSE + relayErrors < RELAYS.length) { | |
| relayErrors = RELAYS.length - relaysEOSE; | |
| updateConnStatus(); | |
| } | |
| }, 15000); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment