clone

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

feat/ui-updates
tinyrelay / tinyclient / tiny.js
tiny.js 9.4K
1// Shared browser helpers. This file is loaded before every feature module and2// the signer bridge. It creates tiny.util for byte, URL, encoding and wiki3// name operations, tiny.signing for verified event signing and publication,4// and tiny.require for loading page-specific bundles once.5(() => {6 "use strict";7 const tiny = globalThis.tiny || {};8 const bytes = value => {9 if (value instanceof Uint8Array) return value;10 if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);11 if (value instanceof ArrayBuffer) return new Uint8Array(value);12 throw new TypeError("expected bytes");13 };14 const hex = value => Array.from(bytes(value), byte => byte.toString(16).padStart(2, "0")).join("");15 const sha256 = async value => new Uint8Array(await crypto.subtle.digest("SHA-256", bytes(value)));16 // fromHex parses a 64-character lowercase hex hash or key.17 const fromHex = (value, message = "expected a 64-character lowercase hex value") => {18 if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) throw Error(message);19 return Uint8Array.from(value.match(/../g), byte => parseInt(byte, 16));20 };21 const element = (tag, text) => {22 const node = document.createElement(tag);23 if (text !== undefined) node.textContent = text;24 return node;25 };26 // relayURL normalizes a ws or wss URL without credentials or a fragment, or27 // returns null. Strict mode also rejects query strings, which operator-entered28 // relay lists never need.29 const relayURL = (value, {strict = false} = {}) => {30 if (typeof value !== "string" || value.length > 2048) return null;31 let url;32 try {33 url = new URL(value.trim());34 } catch {35 return null;36 }37 if (38 (url.protocol !== "wss:" && url.protocol !== "ws:") ||39 !url.hostname ||40 url.username ||41 url.password ||42 url.hash43 )44 return null;45 if (strict && url.search) return null;46 url.hostname = url.hostname.toLowerCase();47 if (url.pathname.length > 1) url.pathname = url.pathname.replace(/\/+$/, "");48 return url.toString().replace(/\/$/, "");49 };50 const b64url = value => {51 let binary = "";52 bytes(value).forEach(byte => {53 binary += String.fromCharCode(byte);54 });55 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");56 };57 const fromB64url = value => {58 const text = value.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((value.length + 3) % 4);59 return Uint8Array.from(atob(text), char => char.charCodeAt(0));60 };61 // bech32Encode renders bytes as a NIP-19 bare string such as an nsec or62 // npub. The browser bundle decodes these but has no plain encoder.63 const bech32Charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";64 const bech32Polymod = values => {65 const generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];66 let checksum = 1;67 for (const value of values) {68 const top = checksum >>> 25;69 checksum = (((checksum & 0x1ffffff) << 5) ^ value) >>> 0;70 generators.forEach((generator, index) => { if ((top >>> index) & 1) checksum = (checksum ^ generator) >>> 0; });71 }72 return checksum;73 };74 const bech32Encode = (prefix, value) => {75 const words = [];76 let accumulator = 0, bits = 0;77 for (const byte of bytes(value)) {78 accumulator = ((accumulator << 8) | byte) >>> 0;79 bits += 8;80 while (bits >= 5) {81 bits -= 5;82 words.push((accumulator >>> bits) & 31);83 }84 }85 if (bits > 0) words.push((accumulator << (5 - bits)) & 31);86 const expanded = [...prefix].map(char => char.charCodeAt(0) >>> 5).concat(0, [...prefix].map(char => char.charCodeAt(0) & 31));87 const polymod = bech32Polymod(expanded.concat(words, [0, 0, 0, 0, 0, 0])) ^ 1;88 const checksum = Array.from({length: 6}, (_, index) => (polymod >>> (5 * (5 - index))) & 31);89 return prefix + "1" + words.concat(checksum).map(word => bech32Charset[word]).join("");90 };91 // wikiName turns a title into a NIP-54 page name the way the relay does:92 // lowercase, whitespace as hyphens, letters, digits and marks kept,93 // everything else dropped, hyphens collapsed and trimmed at both ends.94 const wikiName = title => {95 let out = "";96 for (const ch of String(title ?? "").toLowerCase()) {97 if (/[\s\-_]/u.test(ch)) {98 if (out && !out.endsWith("-")) out += "-";99 } else if (/[\p{L}\p{Nd}\p{M}]/u.test(ch)) {100 out += ch;101 }102 }103 return out.replace(/^-+|-+$/g, "");104 };105 tiny.util = Object.freeze({...tiny.util, bytes, hex, sha256, fromHex, element, relayURL, b64url, fromB64url, bech32Encode, wikiName});106 // signer returns the active signer: a resumed remote signer first, then a107 // NIP-07 extension.108 tiny.signer = () => globalThis.tinySigner || globalThis.nostr;109 const nip44 = (method, pubkey, text, signer = tiny.signer()) => {110 if (typeof signer?.["nip44" + method] === "function") return signer["nip44" + method](pubkey, text);111 const inner = signer?.nip44?.[method.toLowerCase()];112 if (typeof inner === "function") return inner.call(signer.nip44, pubkey, text);113 throw Error("The connected signer does not support NIP-44.");114 };115 tiny.nip44Encrypt = (pubkey, text, signer) => nip44("Encrypt", pubkey, text, signer);116 tiny.nip44Decrypt = (pubkey, text, signer) => nip44("Decrypt", pubkey, text, signer);117 // Event publication is one browser boundary shared by forms, rooms and118 // WebMCP. Signers are treated as untrusted extensions: the returned event119 // must preserve the requested unsigned fields and pass the verifier before120 // it is sent to the relay.121 // Signers may reconstruct the event object while crossing an extension or122 // NIP-46 boundary. Compare the signed event's unsigned fields by value,123 // rather than by JSON property insertion order. Array order remains part of124 // the event and changed values are still rejected.125 const sameValue = (left, right) => {126 if (left === right) return true;127 if (typeof left !== typeof right || left === null || right === null) return false;128 if (Array.isArray(left) || Array.isArray(right)) {129 return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => sameValue(value, right[index]));130 }131 if (typeof left === "object") {132 const leftKeys = Object.keys(left), rightKeys = Object.keys(right);133 return leftKeys.length === rightKeys.length && leftKeys.every(key => Object.prototype.hasOwnProperty.call(right, key) && sameValue(left[key], right[key]));134 }135 return false;136 };137 const signEvent = async (unsigned, {signal} = {}) => {138 if (!globalThis.nostr?.signEvent) throw Error("Connect a signer first.");139 if (typeof globalThis.NostrSigner?.verifyEvent !== "function") throw Error("The signer verifier is still loading. Try again.");140 const requested = JSON.parse(JSON.stringify(unsigned));141 const event = await globalThis.nostr.signEvent(JSON.parse(JSON.stringify(requested)));142 const actual = event && {kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content};143 if (actual && Object.prototype.hasOwnProperty.call(requested, "pubkey")) actual.pubkey = event.pubkey;144 if (!sameValue(actual, requested) || globalThis.NostrSigner.verifyEvent(event) !== true) throw Error("The signer returned an invalid or changed event.");145 if (signal?.aborted) throw Error("Sending canceled.");146 return event;147 };148 const publishEvent = async (unsigned, options = {}) => {149 const event = await signEvent(unsigned, options);150 if (typeof tiny.signedFetch !== "function") throw Error("The signed request bridge is still loading. Try again.");151 const response = await tiny.signedFetch(options.path || "/events", "POST", JSON.stringify(event), {contentType: "application/json", signal: options.signal});152 let result = {};153 try { result = await response.json(); } catch {}154 if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the event.");155 return event;156 };157 tiny.signing = Object.freeze({signEvent, publish: publishEvent});158 // Feature bundles load on the pages that use them and again after an159 // in-place navigation lands on such a page. Each script loads once.160 tiny.bundles = {161 files: ["file-catalog.js", "blossom-encryption.js", "blossom-manifests.js", "blossom-upload.js", "file-messages.js", "file-workspace.js"],162 private: ["private-services.js"]163 };164 const loaded = new Map();165 const loadScript = name => {166 if (!loaded.has(name)) {167 const version = document.documentElement.dataset.scripts || "";168 const present = [...(document.scripts || [])].some(script => (script.src || "").includes("/scripts/" + name));169 loaded.set(name, present ? Promise.resolve() : new Promise((resolve, reject) => {170 const script = document.createElement("script");171 script.src = (tiny.localPath ? tiny.localPath("/scripts/" + name) : "/scripts/" + name) + (version ? "?v=" + version : "");172 script.onload = () => resolve();173 script.onerror = () => { loaded.delete(name); reject(Error("Could not load " + name)); };174 document.head.append(script);175 }));176 }177 return loaded.get(name);178 };179 tiny.require = async bundle => {180 for (const name of tiny.bundles[bundle] || [bundle]) await loadScript(name);181 };182 // Feature modules add to these groups; guards can read them before load.183 tiny.blossom = tiny.blossom || {};184 tiny.files = tiny.files || {};185 globalThis.tiny = tiny;186})();