clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

main
tinyrelay / tinyclient / bridge.js
bridge.js 25.5K
1// Signer bridge: session sign-in and sign-out, NIP-07 and NIP-46 signers,2// Nostr Connect and the NIP-98 signed fetch used by authenticated calls.3// It exposes tiny.localPath, tiny.authorization and tiny.signedFetch, and4// announces a connected signer with tiny:signer.5(() => {6 "use strict";78 const root = (location.pathname.match(/^\/r\/[^/]+/) || [""])[0];9 const localPath = path => path && path.startsWith("/") ? (path === root || path.startsWith(root + "/") ? path : root + path) : path;10 const storagePrefix = "tiny.bunker" + (root || "/");11 const storage = {12 uri: storagePrefix + ".uri",13 key: storagePrefix + ".sk",14 pubkey: storagePrefix + ".identity",15 remote: storagePrefix + ".remote"16 };17 const say = text => {18 const status = document.getElementById("signer-status") || document.getElementById("session-status");19 if (status) status.textContent = text;20 };21 const errorText = err => err instanceof Error ? err.message : String(err);22 const {hex, sha256} = window.tiny.util;23 const sha256hex = async bytes => hex(await sha256(bytes));24 const randomHex = () => { const b = new Uint8Array(32); crypto.getRandomValues(b); return hex(b); };25 const toBytes = body => body instanceof ArrayBuffer ? new Uint8Array(body) : body instanceof Uint8Array ? body : new TextEncoder().encode(body || "");26 const validRelay = value => {27 try { const parsed = new URL(value); return (parsed.protocol === "ws:" || parsed.protocol === "wss:") && parsed.hostname !== ""; } catch { return false; }28 };29 const validBunker = pointer => pointer && /^[0-9a-f]{64}$/i.test(pointer.pubkey || "") && Array.isArray(pointer.relays) && pointer.relays.length > 0 && pointer.relays.every(validRelay);3031 // authorization builds a NIP-98 header for one request body. The body is32 // hashed exactly once and the same bytes must be sent.33 async function authorization(url, method, bytes) {34 if (!window.nostr?.signEvent) throw Error("Connect a Nostr signer first.");35 const event = await window.nostr.signEvent({36 kind: 27235,37 created_at: Math.floor(Date.now() / 1000),38 tags: [["u", url], ["method", method], ["payload", await sha256hex(bytes)], ["nonce", randomHex()]],39 content: ""40 });41 return "Nostr " + btoa(JSON.stringify(event));42 }4344 async function signedFetch(path, method, body, options = {}) {45 path = localPath(path);46 const bytes = toBytes(body);47 const url = new URL(path, location.href).href;48 const request = {49 method,50 signal: options.signal,51 headers: {authorization: await authorization(url, method, bytes), "content-type": options.contentType || "application/octet-stream"}52 };53 if (method !== "GET" && method !== "HEAD") request.body = bytes;54 const response = await fetch(path, request);55 if (!response.ok) throw Error(await response.text());56 return response;57 }5859 const signedSession = path => signedFetch(path, "POST", "");6061 // Shell: theme toggle, the phone menu, and the installable app worker.62 const root_ = document.documentElement;63 const currentTheme = () => root_.dataset.theme || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");64 const savedTheme = localStorage.getItem("tiny.theme");65 if (savedTheme) root_.dataset.theme = savedTheme;66 // The installed app's title bar follows the theme-color meta, so keep it67 // in step with the rail colour of whichever theme is active.68 const paintChrome = () => {69 const rail = getComputedStyle(root_).getPropertyValue("--rail").trim();70 if (rail) document.querySelectorAll('meta[name="theme-color"]').forEach(meta => { meta.content = rail; });71 // The manifest is fetched at install and on later update checks, so72 // point it at the variant whose launch colours match the active theme.73 const manifest = document.querySelector('link[rel="manifest"]');74 if (manifest) manifest.href = localPath("/manifest.webmanifest") + (currentTheme() === "dark" ? "?theme=dark" : "");75 };76 paintChrome();77 matchMedia("(prefers-color-scheme: dark)").addEventListener("change", paintChrome);78 // Browsers that can install the app announce it; the footer then offers79 // a quiet link and nothing else changes until it is pressed.80 let installPrompt = null;81 const installButton = () => document.getElementById("install");82 window.addEventListener("beforeinstallprompt", event => {83 event.preventDefault();84 installPrompt = event;85 const button = installButton();86 if (button) button.hidden = false;87 });88 window.addEventListener("appinstalled", () => {89 installPrompt = null;90 const button = installButton();91 if (button) button.hidden = true;92 });93 const setMenu = open => {94 const menu = document.getElementById("nav-menu");95 if (menu) menu.open = open;96 };97 const setContext = open => {98 const menu = document.getElementById("context-menu");99 if (menu) menu.open = open;100 };101 const shellBindings = new WeakSet();102 let shellDocumentBound = false;103 const bindShell = () => {104 const rail = document.getElementById("rail");105 const themeButton = document.getElementById("theme");106 const navMenu = document.getElementById("nav-menu");107 const contextMenu = document.getElementById("context-menu");108 if (rail && !shellBindings.has(rail)) {109 shellBindings.add(rail);110 rail.addEventListener("click", event => { if (event.target.closest("a")) setMenu(false); });111 }112 const install = installButton();113 if (install && !shellBindings.has(install)) {114 shellBindings.add(install);115 install.hidden = !installPrompt;116 install.addEventListener("click", async () => {117 if (!installPrompt) return;118 const prompt = installPrompt;119 installPrompt = null;120 install.hidden = true;121 try { await prompt.prompt(); } catch {}122 });123 }124 if (themeButton && !shellBindings.has(themeButton)) {125 shellBindings.add(themeButton);126 const label = () => { themeButton.textContent = currentTheme() === "dark" ? "light" : "dark"; };127 label();128 themeButton.addEventListener("click", () => {129 root_.dataset.theme = currentTheme() === "dark" ? "light" : "dark";130 localStorage.setItem("tiny.theme", root_.dataset.theme);131 label();132 paintChrome();133 });134 }135 if (navMenu && !shellBindings.has(navMenu)) {136 shellBindings.add(navMenu);137 navMenu.addEventListener("toggle", () => { if (navMenu.open) setContext(false); });138 }139 if (contextMenu && !shellBindings.has(contextMenu)) {140 shellBindings.add(contextMenu);141 contextMenu.addEventListener("toggle", () => { if (contextMenu.open) setMenu(false); });142 }143 if (!shellDocumentBound) {144 shellDocumentBound = true;145 document.addEventListener("click", event => {146 const nav = document.getElementById("nav-menu");147 const context = document.getElementById("context-menu");148 if ((!nav?.open && !context?.open) || event.target?.closest?.("#rail, #aside, #topbar, #footer")) return;149 setMenu(false);150 setContext(false);151 });152 }153 };154 bindShell();155 document.addEventListener("keydown", event => {156 if (event.key !== "Escape") return;157 const nav = document.getElementById("nav-menu");158 const context = document.getElementById("context-menu");159 if (nav?.open) {160 setMenu(false);161 setContext(false);162 document.getElementById("menu")?.focus();163 } else if (context?.open) {164 setContext(false);165 document.getElementById("context-toggle")?.focus();166 }167 });168 if ("serviceWorker" in navigator) navigator.serviceWorker.register(localPath("/sw.js")).catch(() => {});169 // An installed app asks to keep its cached shell and parked shares. Browsers170 // grant this silently for installed apps, so it never shows a prompt.171 if (matchMedia("(display-mode: standalone)").matches) navigator.storage?.persist?.().catch(() => {});172 Object.assign(window.tiny, {root, localPath, sha256hex, authorization, signedFetch});173 window.tinySignedFetch = signedFetch;174175 const isSignIn = url => url.pathname.replace(/\/+$/, "") === localPath("/signin");176 const returnPath = value => {177 if (!value?.startsWith("/") || value.startsWith("//") || /[\\\u0000-\u001f\u007f]/.test(value)) return null;178 try {179 const url = new URL(value, location.origin);180 const tenant = (url.pathname.match(/^\/r\/[^/]+/) || [""])[0];181 if (url.origin !== location.origin || tenant !== root || isSignIn(url)) return null;182 return url.pathname + url.search + url.hash;183 } catch { return null; }184 };185 const signInDestination = () => {186 const current = new URL(location.href);187 if (!isSignIn(current)) return returnPath(current.pathname + current.search + current.hash) || localPath("/");188 const carried = new URLSearchParams(current.hash.slice(1)).get("return");189 const next = returnPath(carried) || returnPath(current.searchParams.get("next"));190 if (next) return next;191 try {192 const previous = new URL(document.referrer);193 if (previous.origin === location.origin) return returnPath(previous.pathname + previous.search) || localPath("/");194 } catch {}195 return localPath("/");196 };197198 // Keep fragments client-side: file links may carry decryption keys. The199 // server-rendered next query is still a usable fallback without JavaScript.200 document.addEventListener("click", event => {201 const link = event.target?.closest?.("a[href]");202 if (!link) return;203 const current = new URL(location.href);204 let login;205 try { login = new URL(link.href, location.href); } catch { return; }206 if (login.origin !== location.origin || !isSignIn(login) || isSignIn(current)) return;207 const target = returnPath(current.pathname + current.search + current.hash);208 if (!target) return;209 login.searchParams.set("next", current.pathname + current.search);210 login.hash = current.hash ? "return=" + encodeURIComponent(target) : "";211 link.href = login.href;212 if (link.hasAttribute("fx-action")) link.setAttribute("fx-action", login.href);213 }, true);214215 const signIn = async () => {216 const destination = signInDestination();217 say("Signing in…");218 if (sessionActor() && window.tiny.signer()?.getPublicKey) verifySignerIdentity(await window.tiny.signer().getPublicKey());219 await signedSession("/session");220 announceSigner("ready");221 say("Signed in.");222 location.replace(destination);223 };224225 // A remote signer session lives in this tab unless the person chose to226 // remember the device; then it survives the installed app being closed.227 const forgetRemote = () => {228 for (const store of [sessionStorage, localStorage]) {229 Object.values(storage).forEach(name => store.removeItem(name));230 store.removeItem("tiny.bunker");231 }232 };233234 const rememberRemote = async (uri, key, signer) => {235 const store = document.getElementById("remember-signer")?.checked ? localStorage : sessionStorage;236 store.setItem(storage.uri, uri);237 store.setItem(storage.key, window.NostrSigner.bytesToHex(key));238 store.setItem(storage.pubkey, await signer.getPublicKey());239 store.setItem(storage.remote, JSON.stringify(signer.bp));240 };241242 let activeSigner, activeRemote, remoteFacade, nativeSigner = window.nostr;243 let signerStatus = "disconnected", signerMessage = "Signer unavailable.";244 let reconnecting, lastCheck = 0, signerGeneration = 0;245 const pendingCalls = new Map();246 const signerStatusText = {247 disconnected: "Signer unavailable.", checking: "Checking signer…",248 reconnecting: "Reconnecting signer…", lost: "Signer connection lost.", ready: "Signer connected."249 };250 const sessionActor = () => document.querySelector?.("signer-connection")?.getAttribute("actor") || "";251 const nativeProvider = () => {252 if (window.nostr && window.nostr !== remoteFacade) nativeSigner = window.nostr;253 return nativeSigner;254 };255 const withTimeout = (promise, ms, message = "Signer connection timed out.") => {256 let timer;257 return Promise.race([promise, new Promise((_, reject) => {258 timer = setTimeout(() => reject(Error(message)), ms);259 })]).finally(() => clearTimeout(timer));260 };261 const closeRemote = signer => {262 if (!signer) return;263 for (const reject of pendingCalls.get(signer) || []) reject(Error("Signer connection changed. Retry when connected."));264 pendingCalls.delete(signer);265 try { Promise.resolve(signer.close?.()).catch(() => {}); } catch {}266 try { signer.pool?.destroy?.(); } catch {}267 };268 const connectionURL = () => {269 const current = new URL(location.href);270 const url = new URL(localPath("/signin"), location.href);271 url.searchParams.set("connect", "1");272 url.searchParams.set("next", current.pathname + current.search);273 if (current.hash) url.hash = "return=" + encodeURIComponent(current.pathname + current.search + current.hash);274 return url.href;275 };276 const hasRememberedSigner = () => !!(sessionStorage.getItem(storage.key) || localStorage.getItem(storage.key));277 const updateSignerConnection = () => {278 const host = document.querySelector?.("signer-connection");279 if (!host || !document.createElement) return;280 host.hidden = signerStatus === "ready";281 host.setAttribute("data-state", signerStatus);282 host.setAttribute("role", "status");283 host.replaceChildren();284 if (host.hidden) return;285 const message = document.createElement("span");286 message.textContent = signerMessage;287 host.append(message);288 if (signerStatus === "checking" || signerStatus === "reconnecting") return;289 const button = document.createElement("button");290 button.type = "button";291 button.textContent = hasRememberedSigner() || nativeProvider() ? "Reconnect" : "Connect signer";292 button.addEventListener("click", () => {293 if (!hasRememberedSigner() && !nativeProvider()) { location.assign(connectionURL()); return; }294 reconnectSigner({force: true}).catch(() => {});295 });296 host.append(button);297 };298 const announceSigner = (status, message = signerStatusText[status]) => {299 signerStatus = status;300 signerMessage = message;301 updateSignerConnection();302 const statusNode = document.getElementById("session-status");303 if (statusNode) statusNode.textContent = status === "lost" ? message : "";304 if (typeof CustomEvent === "function") document.dispatchEvent?.(new CustomEvent("tiny:signer-status", {305 detail: {status, text: message, canReconnect: status === "lost" || status === "disconnected"}306 }));307 };308 const loseRemote = (remote, message) => {309 if (remote !== activeRemote) return;310 activeRemote = activeSigner = undefined;311 window.tinySigner = undefined;312 if (window.nostr === remoteFacade) window.nostr = nativeSigner;313 closeRemote(remote);314 announceSigner("lost", message || signerStatusText.lost);315 };316 const guardSigner = signer => {317 const methods = new Map();318 return new Proxy(signer, {get(target, property) {319 const value = Reflect.get(target, property, target);320 if (typeof value !== "function") return value;321 if (!/^(?:signEvent|getPublicKey|nip(?:04|44)(?:Encrypt|Decrypt))$/.test(String(property))) return value.bind(target);322 if (!methods.has(property)) methods.set(property, async (...args) => {323 if (target !== activeRemote) throw Error("Signer connection changed. Retry when connected.");324 let cancel;325 const pending = pendingCalls.get(target) || new Set();326 pendingCalls.set(target, pending);327 const canceled = new Promise((_, reject) => { cancel = reject; pending.add(reject); });328 try {329 const result = await withTimeout(Promise.race([Promise.resolve().then(() => value.apply(target, args)), canceled]), property === "signEvent" ? 120000 : 30000);330 if (target !== activeRemote) throw Error("Signer connection changed. Retry when connected.");331 return result;332 } catch (err) {333 if (!/reject|denied|cancel|declin|unsupported|invalid|permission/i.test(errorText(err))) loseRemote(target);334 throw err;335 } finally { pending.delete(cancel); }336 });337 return methods.get(property);338 }});339 };340 const setSigner = signer => {341 const previous = activeRemote;342 signerGeneration++;343 activeRemote = signer;344 activeSigner = guardSigner(signer);345 window.tinySigner = activeSigner;346 nativeProvider();347 remoteFacade = {signEvent: event => activeSigner ? activeSigner.signEvent(event) : Promise.reject(Error("Reconnect your signer first."))};348 window.nostr = remoteFacade;349 if (previous !== signer) closeRemote(previous);350 announceSigner("ready");351 if (typeof CustomEvent === "function") document.dispatchEvent?.(new CustomEvent("tiny:signer"));352 };353 const storedRemote = async () => {354 const store = sessionStorage.getItem(storage.key) ? sessionStorage : localStorage;355 const uri = store.getItem(storage.uri), keyHex = store.getItem(storage.key);356 if (!uri || !keyHex) return null;357 const remote = store.getItem(storage.remote);358 const pointer = remote ? JSON.parse(remote) : await withTimeout(window.NostrSigner.parseBunkerInput(uri), 5000);359 if (!validBunker(pointer)) throw Error("Saved signer connection is invalid. Connect your signer again.");360 return {key: window.NostrSigner.hexToBytes(keyHex), pointer, expected: store.getItem(storage.pubkey)};361 };362 const verifySignerIdentity = (pubkey, expected) => {363 if (!/^[0-9a-f]{64}$/i.test(pubkey || "") || (expected && pubkey.toLowerCase() !== expected.toLowerCase()) || (sessionActor() && pubkey.toLowerCase() !== sessionActor().toLowerCase()))364 throw Error("Choose the signer for your signed-in account.");365 };366 const reconnectSigner = ({force = false} = {}) => {367 if (reconnecting) return reconnecting;368 if (!force && Date.now() - lastCheck < 15000) return Promise.resolve(activeSigner);369 lastCheck = Date.now();370 const generation = signerGeneration;371 // Deferring keeps even an immediately failed attempt in the single-flight slot.372 const task = Promise.resolve().then(async () => {373 if (!hasRememberedSigner()) {374 const native = nativeProvider();375 if (!native?.getPublicKey) { announceSigner("disconnected"); return; }376 announceSigner("checking");377 try {378 verifySignerIdentity(await withTimeout(native.getPublicKey(), 5000));379 if (generation !== signerGeneration) return activeSigner;380 announceSigner("ready");381 if (typeof CustomEvent === "function") document.dispatchEvent?.(new CustomEvent("tiny:signer"));382 return native;383 } catch (err) {384 if (generation === signerGeneration) announceSigner("lost", /signed-in account/.test(errorText(err)) ? errorText(err) : "Unlock or reconnect your signer.");385 throw err;386 }387 }388 if (!force && activeRemote?.ping) {389 const previous = activeRemote;390 announceSigner("checking");391 try {392 await withTimeout(previous.ping(), 5000);393 if (generation !== signerGeneration) return activeSigner;394 announceSigner("ready");395 return activeSigner;396 } catch { if (generation !== signerGeneration) return activeSigner; }397 }398 announceSigner("reconnecting");399 let candidate;400 try {401 const stored = await storedRemote();402 if (!stored) { announceSigner("disconnected"); return; }403 candidate = window.NostrSigner.BunkerSigner.fromBunker(stored.key, stored.pointer);404 await withTimeout(candidate.connect(), 8000);405 verifySignerIdentity(await withTimeout(candidate.getPublicKey(), 5000), stored.expected);406 if (generation !== signerGeneration) { closeRemote(candidate); return activeSigner; }407 setSigner(candidate);408 return activeSigner;409 } catch (err) {410 closeRemote(candidate);411 if (generation === signerGeneration) {412 const previous = activeRemote;413 activeRemote = activeSigner = undefined;414 window.tinySigner = undefined;415 if (window.nostr === remoteFacade) window.nostr = nativeSigner;416 closeRemote(previous);417 announceSigner("lost", /signed-in account/.test(errorText(err)) ? errorText(err) : signerStatusText.lost);418 }419 throw err;420 }421 });422 reconnecting = task;423 task.finally(() => { if (reconnecting === task) reconnecting = undefined; }).catch(() => {});424 return task;425 };426 Object.assign(window.tiny, {signerState: () => signerStatus, reconnectSigner});427 document.addEventListener("tiny:navigation", updateSignerConnection);428 const resumeSigner = () => {429 if (document.visibilityState && document.visibilityState !== "visible") return;430 if (hasRememberedSigner() || nativeProvider()) reconnectSigner().catch(() => {});431 else updateSignerConnection();432 };433 document.addEventListener("visibilitychange", () => {434 if (document.visibilityState === "hidden") { lastCheck = -Infinity; return; }435 resumeSigner();436 });437 window.addEventListener("pageshow", event => { if (event.persisted) lastCheck = -Infinity; resumeSigner(); });438 document.addEventListener("resume", () => { lastCheck = -Infinity; resumeSigner(); });439 window.addEventListener("nostr:ready", () => { lastCheck = -Infinity; resumeSigner(); });440 window.addEventListener("focus", resumeSigner);441 window.addEventListener("online", () => { lastCheck = -Infinity; resumeSigner(); });442 window.addEventListener("offline", () => { if (activeRemote) loseRemote(activeRemote, "Offline. Reconnect when online."); });443 updateSignerConnection();444445 // Fixi can morph the content column without reloading this script. Bind446 // controls by element identity so navigation can safely discover new ones.447 const boundControls = new WeakSet();448 const bindControl = (id, event, handler) => {449 const element = document.getElementById(id);450 if (!element || boundControls.has(element)) return;451 boundControls.add(element);452 element.addEventListener(event, handler);453 };454 const busy = new WeakSet();455 const once = (element, action) => {456 if (busy.has(element)) return;457 busy.add(element);458 const button = element.matches?.("button, input[type=submit]") ? element : element.querySelector?.("button, input[type=submit]");459 if (button) button.disabled = true;460 return Promise.resolve().then(action).finally(() => { busy.delete(element); if (button) button.disabled = false; });461 };462463 const bindControls = () => {464 bindControl("session-login", "click", event => once(event.currentTarget, async () => {465 try { await signIn(); } catch (err) { say("Sign-in error: " + errorText(err)); }466 }));467468 bindControl("session-logout", "click", event => once(event.currentTarget, async () => {469 try {470 say("Signing out…");471 const response = await fetch(localPath("/session/logout"), {method: "POST", credentials: "same-origin"});472 if (!response.ok) throw Error(await response.text());473 signerGeneration++;474 closeRemote(activeRemote);475 activeRemote = activeSigner = undefined;476 window.tinySigner = undefined;477 forgetRemote();478 if (typeof CustomEvent === "function") document.dispatchEvent?.(new CustomEvent("tiny:logout"));479 say("Signed out.");480 location.reload();481 } catch (err) {482 say("Sign-out error: " + errorText(err));483 }484 }));485486 // Nostr Connect: show a QR code and wait for a phone signer such as Amber.487 bindControl("nostrconnect", "click", event => {488 event.preventDefault();489 return once(event.currentTarget, async () => {490 try {491 const key = window.NostrSigner.generateSecretKey();492 const uri = window.NostrSigner.createNostrConnectURI({493 clientPubkey: window.NostrSigner.getPublicKey(key),494 relays: [location.origin.replace(/^http/,"ws")+root],495 secret: randomHex(),496 name: "tiny",497 url: location.origin,498 perms: ["sign_event:27235"]499 });500 const open = document.getElementById("nostrconnect-open");501 if (open) { open.href = uri; open.hidden = false; }502 const qr = document.getElementById("signer-qr");503 if (qr) { qr.src = root + "/qr.svg?text=" + encodeURIComponent(uri); qr.hidden = false; }504 navigator.clipboard?.writeText(uri).catch(() => {});505 say("Scan the QR code or open the link in your signer.");506 window.tinyConnect = {key, uri};507 const signer = await window.NostrSigner.BunkerSigner.fromURI(key, uri);508 setSigner(signer);509 await rememberRemote(uri, key, signer);510 await signIn();511 } catch (err) {512 say("Nostr Connect error: " + errorText(err));513 }514 });515 });516517 // Bunker URL: connect to a remote signer the user pastes in.518 bindControl("bunker", "submit", event => {519 event.preventDefault();520 return once(event.currentTarget, async () => {521 const value = document.getElementById("bunker-url")?.value.trim();522 if (!value) return;523 try {524 const secret = window.NostrSigner.generateSecretKey();525 const pointer = await window.NostrSigner.parseBunkerInput(value);526 if (!validBunker(pointer)) throw Error("Enter a valid bunker URL with at least one relay.");527 const signer = window.NostrSigner.BunkerSigner.fromBunker(secret, pointer);528 await signer.connect();529 setSigner(signer);530 await rememberRemote(value, secret, signer);531 await signIn();532 } catch (err) {533 say("Remote signer error: " + errorText(err));534 }535 });536 });537 };538 bindControls();539 document.addEventListener("tiny:navigation", () => { bindShell(); bindControls(); });540541 // Resume a remote signer stored for this tab so page loads keep working.542 (async () => {543 try {544 if (hasRememberedSigner() || nativeProvider()) await reconnectSigner({force: true});545 } catch {}546 })();547})();