From 4152f96be07591ec92c6719a1fa14a456d182126 Mon Sep 17 00:00:00 2001 From: Jonathan Staab Date: Mon, 20 Mar 2023 08:50:27 -0500 Subject: [PATCH] Add support for AUTH --- CHANGELOG.md | 7 ++ ROADMAP.md | 5 +- src/App.svelte | 10 ++- src/agent/cmd.ts | 9 +++ src/agent/pool.ts | 151 +++++++++++++++++++++++--------------- src/agent/user.ts | 4 +- src/partials/Input.svelte | 2 +- src/util/misc.ts | 29 +++++++- 8 files changed, 149 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b5d3c59..875f3765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.2.19 + +- [x] Add confirmation to zap dialog +- [x] Avoid pruning profiles we know we'll use more often +- [x] Re-write pool to remove dependency on nostr-tools.relay +- [x] Add support for AUTH + ## 0.2.18 - [x] Re-write data storage layer to conserve memory using a custom LRU cache diff --git a/ROADMAP.md b/ROADMAP.md index 8653f3d4..2bb4f3f8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,9 +1,10 @@ # Current - [ ] https://github.com/staab/coracle/issues/42 -- [ ] Show loading/success on zap invoice screen - [ ] Fix iOS/safari/firefox -- [ ] Add AUTH +- [ ] Multiplex, charge past a certain usage level based on bandwidth +- [ ] Fix compose, topics +- [ ] Fix onboarding workflow w/forced relays # Others diff --git a/src/App.svelte b/src/App.svelte index 70fc5d81..79b1de95 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -12,7 +12,7 @@ import {find, is, identity, nthArg, pluck} from "ramda" import {log, warn} from "src/util/logger" import {timedelta, hexToBech32, bech32ToHex, shuffle, now, sleep} from "src/util/misc" - import {displayPerson, isLike} from "src/util/nostr" + import {displayPerson, Tags, isLike} from "src/util/nostr" import cmd from "src/agent/cmd" import {onReady, relays, people} from "src/agent/tables" import keys from "src/agent/keys" @@ -83,6 +83,14 @@ $: style.textContent = `:root { ${getThemeVariables($theme)}; background: var(--gray-8); }` + // When we get an AUTH challenge from our pool, attempt to authenticate + pool.eventBus.on("AUTH", async (challenge, connection) => { + const publishable = cmd.authenticate(challenge, url) + const [event] = await publishable.publish([{url: connection.url}]) + + connection.checkAuth(event.id) + }) + onMount(() => { // Keep scroll position on body, but don't allow scrolling const unsubModal = modal.subscribe($modal => { diff --git a/src/agent/cmd.ts b/src/agent/cmd.ts index 100a2100..3301b30e 100644 --- a/src/agent/cmd.ts +++ b/src/agent/cmd.ts @@ -7,6 +7,14 @@ import pool from "src/agent/pool" import sync from "src/agent/sync" import keys from "src/agent/keys" +const authenticate = (challenge, relay) => + new PublishableEvent(22242, { + tags: [ + ["challenge", challenge], + ["relay", relay], + ], + }) + const updateUser = updates => new PublishableEvent(0, {content: JSON.stringify(updates)}) const setRelays = newRelays => @@ -140,6 +148,7 @@ class PublishableEvent { } export default { + authenticate, updateUser, setRelays, setPetnames, diff --git a/src/agent/pool.ts b/src/agent/pool.ts index 438094da..964bad42 100644 --- a/src/agent/pool.ts +++ b/src/agent/pool.ts @@ -1,11 +1,12 @@ import type {Relay, Filter} from "nostr-tools" import type {Deferred} from "src/util/misc" import type {MyEvent} from "src/util/types" +import {throttle} from 'throttle-debounce' import {verifySignature} from "nostr-tools" import {pluck, objOf, identity, is} from "ramda" import {ensurePlural, noop} from "hurdak/lib/hurdak" import {warn, log, error} from "src/util/logger" -import {union, defer, tryJson, now, difference} from "src/util/misc" +import {union, EventBus, defer, tryJson, difference} from "src/util/misc" import {isRelay, normalizeRelayUrl} from "src/util/nostr" const forceRelays = (import.meta.env.VITE_FORCE_RELAYS || "") @@ -15,14 +16,20 @@ const forceRelays = (import.meta.env.VITE_FORCE_RELAYS || "") // Connection management +const eventBus = new EventBus() + const connections = {} const CONNECTION_STATUS = { NEW: "new", - ERROR: "error", PENDING: "pending", CLOSED: "closed", READY: "ready", + AUTH: "auth", + ERROR: { + CONNECTION: "error/connection", + AUTH: "error/auth", + }, } class Connection { @@ -30,27 +37,17 @@ class Connection { url: string promise?: Deferred queue: string[] - status: string - closed?: number + status: {code: string; message: string; occurredAt: number} timeout?: number - listeners: Record void>> stats: Record + bus: EventBus constructor(url) { this.ws = null this.url = url this.promise = null this.queue = [] - this.status = CONNECTION_STATUS.NEW - this.closed = null this.timeout = null - - this.listeners = { - OK: {}, - ERROR: {}, - EVENT: {}, - EOSE: {}, - } - + this.bus = new EventBus() this.stats = { timeouts: 0, subsCount: 0, @@ -60,20 +57,24 @@ class Connection { activeSubsCount: 0, } + this.setStatus(CONNECTION_STATUS.NEW, "Waiting to connect") + connections[url] = this } + setStatus(code, message, extra = {}) { + this.status = {code, message, ...extra, occurredAt: Date.now()} + } connect() { if (this.ws) { throw new Error("Attempted to connect when already connected") } - this.status = CONNECTION_STATUS.PENDING - this.ws = new WebSocket(this.url) this.promise = defer() - this.closed = null + this.ws = new WebSocket(this.url) + this.setStatus(CONNECTION_STATUS.PENDING, "Trying to connect") this.ws.addEventListener("open", () => { - this.status = CONNECTION_STATUS.READY + this.setStatus(CONNECTION_STATUS.READY, "Connected") this.promise.resolve() }) @@ -85,30 +86,48 @@ class Connection { } }) - this.ws.addEventListener("error", () => { - this.status = CONNECTION_STATUS.ERROR + this.ws.addEventListener("error", e => { + this.disconnect(CONNECTION_STATUS.ERROR.CONNECTION, "Failed to connect") this.promise.reject() - this.closed = now() }) this.ws.addEventListener("close", () => { - this.status = CONNECTION_STATUS.CLOSED + this.disconnect() this.promise.reject() - this.closed = now() }) + + // Propagate auth to global handler + this.bus.on("AUTH", throttle(3000, challenge => { + this.setStatus(CONNECTION_STATUS.AUTH, "Logging in") + + eventBus.handle("AUTH", challenge, this) + })) } - disconnect() { + disconnect(error = null) { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.close() } + if (error) { + this.setStatus(...error) + } else { + this.setStatus(CONNECTION_STATUS.CLOSED, "Closed") + } + this.ws = null } async autoConnect() { - if (this.status === CONNECTION_STATUS.NEW) { + const {code, occurredAt} = this.status + const {NEW, CLOSED} = CONNECTION_STATUS + + + // If the connection has not been opened, or was closed, open 'er up + if ([NEW, CLOSED].includes(code)) { this.connect() - } else if (this.closed && now() - 10 > this.closed) { - // If the connection was closed, try to re-open, but throttle it + } + + // If the connection failed, try to re-open after a while + if (code.startsWith("error") && Date.now() - 30_000 > occurredAt) { this.disconnect() this.connect() } @@ -117,22 +136,14 @@ class Connection { return this } - on(name, id, cb) { - this.listeners[name][id] = cb - } - off(name, id) { - delete this.listeners[name][id] - } handleMessages() { for (const json of this.queue.splice(0, 10)) { const message = tryJson(() => JSON.parse(json)) if (message) { - const [verb, ...payload] = message + const [k, ...payload] = message - for (const listener of Object.values(this.listeners[verb] || {})) { - listener(...payload) - } + this.bus.handle(k, ...payload) } } @@ -142,8 +153,10 @@ class Connection { this.ws.send(JSON.stringify(payload)) } subscribe(filters, id, {onEvent, onEose}) { - this.on("EVENT", id, (subid, e) => subid === id && onEvent(e)) - this.on("EOSE", id, subid => subid === id && onEose()) + const [eventChannel, eoseChannel] = [ + this.bus.on("EVENT", (subid, e) => subid === id && onEvent(e)), + this.bus.on("EOSE", subid => subid === id && onEose()), + ] this.send("REQ", id, ...filters) @@ -152,31 +165,46 @@ class Connection { unsub: () => { this.send("CLOSE", id, ...filters) - this.off("EVENT", id) - this.off("EOSE", id) + this.bus.off("EVENT", eventChannel) + this.bus.off("EOSE", eoseChannel) }, } } publish(event, {onOk, onError}) { - const withCleanup = f => eid => { - if (eid === event.id) { - f() - this.off("OK", event.id) - this.off("ERROR", event.id) + const withCleanup = cb => k => { + if (k === event.id) { + cb() + this.bus.off("OK", okChannel) + this.bus.off("ERROR", errorChannel) } } - this.on("OK", event.id, withCleanup(onOk)) - this.on("ERROR", event.id, withCleanup(onError)) + const [okChannel, errorChannel] = [ + this.bus.on("OK", withCleanup(onOk)), + this.bus.on("ERROR", withCleanup(onError)), + ] this.send("EVENT", event) } + checkAuth(eid) { + const channel = this.bus.on("OK", (id, ok, message) => { + if (id === eid) { + if (ok) { + this.setStatus(CONNECTION_STATUS.READY, "Connected") + } else { + this.disconnect(CONNECTION_STATUS.ERROR.AUTH, message) + } + + this.bus.off("OK", channel) + } + }) + } hasRecentError() { - return this.status === CONNECTION_STATUS.ERROR && now() - this.closed < 10 + return this.status.code.startsWith("error") && Date.now() - this.status.occurredAt < 30_000 } getQuality() { - if (this.status === CONNECTION_STATUS.ERROR) { - return [0, "Failed to connect"] + if (this.status.code.startsWith("error")) { + return [0, this.status.message] } const {timeouts, subsCount, eoseTimer, eoseCount} = this.stats @@ -195,15 +223,13 @@ class Connection { return [eoseQuality, "Connected"] } - if ([CONNECTION_STATUS.NEW, CONNECTION_STATUS.PENDING].includes(this.status)) { - return [0.5, "Trying to connect"] + const {NEW, PENDING, AUTH, CLOSED, READY} = CONNECTION_STATUS + + if ([NEW, PENDING, AUTH, CLOSED].includes(this.status.code)) { + return [0.5, this.status.message] } - if (this.status === CONNECTION_STATUS.CLOSED) { - return [0.5, "Disconnected"] - } - - if (this.status === CONNECTION_STATUS.READY) { + if (this.status.code === READY) { return [1, "Connected"] } } @@ -300,8 +326,10 @@ const publish = async ({relays, event, onProgress, timeout = 5000}) => { relays.map(async relay => { const conn = await connect(relay.url) + const {READY, AUTH} = CONNECTION_STATUS + const canPublish = [READY, AUTH].includes(conn.status.code) - if (conn.status === CONNECTION_STATUS.READY) { + if (canPublish) { conn.publish(event, { onOk: () => { succeeded.add(relay.url) @@ -360,7 +388,7 @@ const subscribe = async ({relays, filter, onEvent, onEose, onError}: SubscribeOp const promises = relays.map(async relay => { const conn = await connect(relay.url) - if (conn.status !== "ready") { + if (conn.status.code !== CONNECTION_STATUS.READY) { if (onError) { onError(relay.url) } @@ -452,6 +480,7 @@ const describeFilter = ({kinds = [], ...filter}) => { } export default { + eventBus, forceRelays, getConnections, getConnection, diff --git a/src/agent/user.ts b/src/agent/user.ts index 3a09c088..3c87a2ec 100644 --- a/src/agent/user.ts +++ b/src/agent/user.ts @@ -1,6 +1,6 @@ import type {Relay} from "src/util/types" import type {Readable} from "svelte/store" -import {slice, prop, find, pipe, assoc, whereEq, when, concat, reject, nth, map} from "ramda" +import {slice, uniqBy, prop, find, pipe, assoc, whereEq, when, concat, reject, nth, map} from "ramda" import {findReplyId, findRootId} from "src/util/nostr" import {synced} from "src/util/misc" import {derived} from "svelte/store" @@ -98,7 +98,7 @@ export default { relays, getRelays: () => profileCopy.relays, updateRelays(f) { - const $relays = f(profileCopy.relays) + const $relays = uniqBy(prop('url'), f(profileCopy.relays)) profile.update(assoc("relays", $relays)) diff --git a/src/partials/Input.svelte b/src/partials/Input.svelte index 726a8fb1..f5ad3415 100644 --- a/src/partials/Input.svelte +++ b/src/partials/Input.svelte @@ -7,7 +7,7 @@ const className = cx( $$props.class, - "rounded shadow-inset py-2 px-4 pr-10 w-full placeholder:text-gray-4", + "rounded shadow-inset py-2 px-4 pr-10 w-full placeholder:text-gray-5", "bg-input border border-solid border-gray-3 text-black", {"pl-10": $$slots.before, "pr-10": $$slots.after} ) diff --git a/src/util/misc.ts b/src/util/misc.ts index 15dc0a20..12a68c72 100644 --- a/src/util/misc.ts +++ b/src/util/misc.ts @@ -2,6 +2,8 @@ import {bech32, utf8} from "@scure/base" import {debounce, throttle} from "throttle-debounce" import { gt, + whereEq, + reject, mergeDeepRight, aperture, path as getPath, @@ -18,7 +20,7 @@ import { } from "ramda" import Fuse from "fuse.js/dist/fuse.min.js" import {writable} from "svelte/store" -import {isObject, round} from "hurdak/lib/hurdak" +import {isObject, randomId, round} from "hurdak/lib/hurdak" import {warn} from "src/util/logger" export const fuzzy = (data, opts = {}) => { @@ -394,3 +396,28 @@ export const formatSats = sats => { if (sats < 100_000_000) return formatter.format(round(1, sats / 1_000_000)) + "MM" return formatter.format(round(2, sats / 100_000_000)) + "BTC" } + +type EventBusListener = { + id: string + handler: (...args: any[]) => void +} + +export class EventBus { + listeners: Record> = {} + on(name, handler) { + const id = randomId() + + this.listeners[name] = this.listeners[name] || ([] as Array) + this.listeners[name].push({id, handler}) + + return id + } + off(name, id) { + this.listeners[name] = reject(whereEq({id}), this.listeners[name]) + } + handle(k, ...payload) { + for (const {handler} of this.listeners[k] || []) { + handler(...payload) + } + } +}