clone

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

feat/standalone-relay
tinyrelay / tinyclient / file-messages.js
file-messages.js 12.2K
1/* NIP-17 file messages. The module builds encrypted rumors and gift-wrap2 events, discovers inbox relays and sends bounded deliveries. */3(() => {4 "use strict";5 const {relayURL} = globalThis.tiny.util;6 const {signer: activeSigner, nip44Encrypt} = globalThis.tiny;78 const MAX_RELAYS = 8;9 const DAY = 86400;1011 function api() {12 const signer = globalThis.NostrSigner;13 if (!signer || !signer.nip44 || !signer.finalizeEvent || !signer.generateSecretKey) {14 throw Error("NIP-44 support is unavailable");15 }16 return signer;17 }1819 function relayList(event, pubkey) {20 if (Array.isArray(event)) {21 event = event22 .filter(candidate => candidate?.kind === 10050 && candidate.pubkey === pubkey)23 .sort((a, b) => b.created_at - a.created_at)[0];24 }25 if (!event || event.kind !== 10050 || event.pubkey !== pubkey) {26 throw Error("recipient has no valid kind 10050 relay list");27 }28 const signer = api();29 if (!signer.verifyEvent(event)) throw Error("invalid kind 10050 relay list signature");30 const relays = [31 ...new Set(32 event.tags33 .filter(tag => tag[0] === "relay")34 .map(tag => relayURL(tag[1]))35 .filter(Boolean)36 )37 ];38 if (!relays.length) throw Error("kind 10050 relay list is empty");39 return relays.slice(0, MAX_RELAYS);40 }4142 function normalizePubkey(value) {43 const text = String(value || "").trim();44 if (/^[0-9a-f]{64}$/i.test(text)) return text.toLowerCase();45 const decoded = api().decodeNpub?.(text);46 if (typeof decoded === "string" && /^[0-9a-f]{64}$/i.test(decoded)) return decoded.toLowerCase();47 throw Error("public key must be a 64 character hex key or npub");48 }4950 async function bounded(value, timeout, label) {51 const ms = Number.isFinite(timeout) && timeout > 0 ? timeout : 10000;52 let timer;53 try {54 return await Promise.race([55 Promise.resolve(value),56 new Promise((_, reject) => {57 timer = setTimeout(() => reject(Error(label + " timed out")), ms);58 })59 ]);60 } finally {61 clearTimeout(timer);62 }63 }6465 function sent(result) {66 if (result === false || result == null) return false;67 if (Array.isArray(result)) return result.some(sent);68 if (typeof result === "object" && "ok" in result) return result.ok === true;69 return true;70 }7172 function past(now) {73 return Math.max(0, Math.floor(now) - Math.floor(Math.random() * (2 * DAY + 1)));74 }7576 async function publicKey(signer, getPublicKey) {77 const key = getPublicKey ? await getPublicKey() : await signer.getPublicKey();78 if (typeof key !== "string" || !/^[0-9a-f]{64}$/i.test(key)) throw Error("invalid signer public key");79 return key.toLowerCase();80 }8182 async function queryRelayList(pubkey, options) {83 if (!globalThis.tiny?.signedFetch) throw Error("signed local query is unavailable");84 const response = await globalThis.tiny.signedFetch(85 "/query",86 "POST",87 JSON.stringify([{kinds: [10050], authors: [pubkey], limit: 1}]),88 {contentType: "application/json"}89 );90 return response.json();91 }9293 function publishEvent(event, relays, options = {}) {94 const signer = options.signer || activeSigner();95 const WebSocketImpl = options.WebSocket || globalThis.WebSocket;96 if (typeof WebSocketImpl !== "function") throw Error("WebSocket support is unavailable");97 const timeout = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 10000;98 return Promise.all(99 relays.map(100 url =>101 new Promise(resolve => {102 let socket;103 let timer;104 let settled = false;105 let authID = "";106 let authAttempts = 0;107 let eventSent = false;108 const finish = ok => {109 if (settled) return;110 settled = true;111 clearTimeout(timer);112 try {113 socket?.close();114 } catch {}115 resolve(ok);116 };117 const sendEvent = () => {118 if (!settled && !eventSent) {119 eventSent = true;120 socket.send(JSON.stringify(["EVENT", event]));121 }122 };123 try {124 socket = new WebSocketImpl(url);125 timer = setTimeout(() => finish(false), timeout);126 socket.onopen = sendEvent;127 socket.onmessage = async message => {128 let frame;129 try {130 frame = JSON.parse(typeof message.data === "string" ? message.data : "");131 } catch {132 return;133 }134 if (settled || !Array.isArray(frame)) return;135 if (frame[0] === "OK" && frame[1] === authID) {136 if (frame[2] === true) {137 eventSent = false;138 sendEvent();139 } else finish(false);140 return;141 }142 if (frame[0] === "OK" && frame[1] === event.id) {143 if (144 frame[2] !== true &&145 String(frame[3] || "")146 .toLowerCase()147 .startsWith("auth-required")148 ) {149 eventSent = false;150 return;151 }152 finish(frame[2] === true);153 return;154 }155 if (frame[0] !== "AUTH" || typeof signer?.signEvent !== "function") return;156 if (typeof frame[1] !== "string" || frame[1].length > 4096) {157 finish(false);158 return;159 }160 if (authAttempts++ >= 1) {161 finish(false);162 return;163 }164 try {165 const auth = await signer.signEvent({166 kind: 22242,167 created_at: Math.floor(Date.now() / 1000),168 tags: [169 ["relay", url],170 ["challenge", frame[1]],171 ["method", "AUTH"]172 ],173 content: ""174 });175 if (176 auth &&177 !settled &&178 auth.kind === 22242 &&179 auth.content === "" &&180 auth.tags.length === 3 &&181 auth.tags[0][0] === "relay" &&182 auth.tags[0][1] === url &&183 auth.tags[1][0] === "challenge" &&184 auth.tags[1][1] === frame[1] &&185 auth.tags[2][0] === "method" &&186 auth.tags[2][1] === "AUTH" &&187 api().verifyEvent(auth) &&188 auth.pubkey === (await publicKey(signer))189 ) {190 authID = auth.id;191 socket.send(JSON.stringify(["AUTH", auth]));192 } else finish(false);193 } catch {194 finish(false);195 }196 };197 socket.onerror = () => finish(false);198 socket.onclose = () => finish(false);199 } catch {200 finish(false);201 }202 })203 )204 );205 }206207 function unsignedRumor(input, sender, now) {208 if (!input.fileURL || !/^https?:\/\//i.test(input.fileURL)) throw Error("fileURL must be an HTTP(S) URL");209 if (!/^[0-9a-f]{64}$/i.test(input.ciphertextHash || "")) throw Error("ciphertextHash must be SHA-256 hex");210 if (!input.key || !input.nonce) throw Error("encrypted file messages require a decryption key and nonce");211 const tags = [["p", input.recipient]];212 if (input.mimeType) tags.push(["file-type", input.mimeType]);213 tags.push(["encryption-algorithm", "aes-gcm"]);214 if (input.key) tags.push(["decryption-key", input.key]);215 if (input.nonce) tags.push(["decryption-nonce", input.nonce]);216 tags.push(["x", input.ciphertextHash.toLowerCase()]);217 if (input.plaintextHash) {218 if (!/^[0-9a-f]{64}$/i.test(input.plaintextHash)) throw Error("plaintextHash must be SHA-256 hex");219 tags.push(["ox", input.plaintextHash.toLowerCase()]);220 }221 if (input.size != null) tags.push(["size", String(input.size)]);222 const rumor = {223 pubkey: sender,224 created_at: Math.floor(now),225 kind: 15,226 tags,227 content: input.fileURL228 };229 rumor.id = api().getEventHash(rumor);230 return rumor;231 }232233 const encryptFor = (signer, recipient, plaintext) => nip44Encrypt(recipient, plaintext, signer);234235 async function seal(rumor, recipient, signer, now) {236 const content = await encryptFor(signer, recipient, JSON.stringify(rumor));237 const unsigned = {238 kind: 13,239 tags: [],240 content,241 created_at: past(now),242 pubkey: rumor.pubkey243 };244 const event = await signer.signEvent(unsigned);245 if (246 !event ||247 event.kind !== 13 ||248 event.pubkey !== rumor.pubkey ||249 event.tags.length ||250 event.content !== unsigned.content ||251 event.created_at !== unsigned.created_at252 )253 throw Error("signer returned an invalid NIP-17 seal");254 if (!api().verifyEvent(event)) throw Error("signer returned an invalid seal signature");255 return event;256 }257258 function wrap(sealEvent, recipient, now) {259 const signer = api();260 const secret = signer.generateSecretKey();261 const pubkey = signer.getPublicKey(secret);262 const key = signer.nip44.getConversationKey(secret, recipient);263 const content = signer.nip44.encrypt(JSON.stringify(sealEvent), key);264 const event = signer.finalizeEvent({kind: 1059, tags: [["p", recipient]], content, created_at: past(now)}, secret);265 if (!signer.verifyEvent(event)) throw Error("failed to verify gift wrap");266 return {event, secret, pubkey};267 }268269 async function build(input, options = {}) {270 const signer = options.signer || activeSigner();271 if (272 !signer ||273 typeof signer.signEvent !== "function" ||274 (typeof signer.nip44Encrypt !== "function" && !signer.nip44?.encrypt)275 )276 throw Error("a NIP-44 signer is required");277 const recipient = normalizePubkey(input.recipient);278 const sender = await publicKey(signer, options.getPublicKey);279 const now = options.now || Date.now() / 1000;280 const rumor = unsignedRumor({...input, recipient}, sender, now);281 const receiverSeal = await seal(rumor, recipient, signer, now);282 const senderSeal = await seal(rumor, sender, signer, now);283 const receiver = wrap(receiverSeal, recipient, now).event;284 const own = wrap(senderSeal, sender, now).event;285 return {286 rumor,287 events: [288 {recipient, event: receiver},289 {recipient: sender, event: own}290 ]291 };292 }293294 async function share(input, options = {}) {295 const signer = options.signer || activeSigner();296 const query = options.queryRelayList || (pubkey => queryRelayList(pubkey, options));297 const publish = options.publish || ((event, relays) => publishEvent(event, relays, options));298 if (typeof query !== "function" || typeof publish !== "function")299 throw Error("private NIP-17 delivery is unavailable");300 const sender = await publicKey(signer, options.getPublicKey);301 const recipient = normalizePubkey(input.recipient);302 const timeout = options.timeoutMs;303 const receiverList = relayList(await bounded(query(recipient), timeout, "recipient relay lookup"), recipient);304 const senderList = relayList(await bounded(query(sender), timeout, "sender relay lookup"), sender);305 const built = await build(input, options);306 if (built.rumor.pubkey !== sender) throw Error("signer changed during file sharing; try again");307 const deliveries = [];308 for (const target of built.events) {309 const relays = target.recipient === recipient ? receiverList : senderList;310 const delivery = await bounded(publish(target.event, relays), timeout, "NIP-17 delivery");311 if (!sent(delivery)) throw Error("NIP-17 delivery failed on every inbox relay");312 deliveries.push(delivery);313 }314 return {315 ...built,316 relays: {recipient: receiverList, sender: senderList},317 deliveries318 };319 }320321 const apiObject = Object.freeze({322 build,323 share,324 normalizePubkey,325 relayList,326 queryRelayList,327 publishEvent328 });329 globalThis.tiny = globalThis.tiny || {};330 globalThis.tiny.files = {...globalThis.tiny.files, messages: apiObject};331})();