From f01b4c079149b7cabb84aaed5e210c804ab2bbe9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 8 Mar 2025 18:28:32 -0600 Subject: [PATCH] Look up identifiers in search on remote relays --- packages/ditto/app.ts | 6 +- packages/ditto/controllers/api/search.ts | 75 +--------- packages/ditto/utils/lookup.ts | 166 ++++++++++++++++++++++- 3 files changed, 171 insertions(+), 76 deletions(-) diff --git a/packages/ditto/app.ts b/packages/ditto/app.ts index af0f9cfb..d656644a 100644 --- a/packages/ditto/app.ts +++ b/packages/ditto/app.ts @@ -8,7 +8,7 @@ import { type Context, Handler, Input as HonoInput, MiddlewareHandler } from '@h import { every } from '@hono/hono/combine'; import { cors } from '@hono/hono/cors'; import { serveStatic } from '@hono/hono/deno'; -import { NostrEvent, NostrSigner, NRelay, NUploader } from '@nostrify/nostrify'; +import { NostrEvent, NostrSigner, NPool, NRelay, NUploader } from '@nostrify/nostrify'; import { cron } from '@/cron.ts'; import { startFirehose } from '@/firehose.ts'; @@ -167,6 +167,7 @@ export interface AppEnv extends DittoEnv { /** User's relay. Might filter out unwanted content. */ relay: NRelay; }; + pool?: NPool; }; } @@ -234,8 +235,9 @@ const socketTokenMiddleware = tokenMiddleware((c) => { app.use( '/api/*', - (c, next) => { + (c: Context } }>, next) => { c.set('relay', new DittoAPIStore({ relay, pool })); + c.set('pool', pool); return next(); }, metricsMiddleware, diff --git a/packages/ditto/controllers/api/search.ts b/packages/ditto/controllers/api/search.ts index 964f0729..f4fade5b 100644 --- a/packages/ditto/controllers/api/search.ts +++ b/packages/ditto/controllers/api/search.ts @@ -1,13 +1,11 @@ import { paginated, paginatedList } from '@ditto/mastoapi/pagination'; import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; -import { nip19 } from 'nostr-tools'; import { z } from 'zod'; import { AppContext, AppController } from '@/app.ts'; import { booleanParamSchema } from '@/schema.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { extractIdentifier, lookupPubkey } from '@/utils/lookup.ts'; -import { lookupNip05 } from '@/utils/nip05.ts'; +import { extractIdentifier, lookupEvent, lookupPubkey } from '@/utils/lookup.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; import { getFollowedPubkeys } from '@/queries.ts'; @@ -34,7 +32,11 @@ const searchController: AppController = async (c) => { return c.json({ error: 'Bad request', schema: result.error }, 422); } - const event = await lookupEvent(c, { ...result.data, ...pagination }); + if (!c.var.pool) { + throw new Error('Ditto pool not available'); + } + + const event = await lookupEvent(result.data.q, { ...c.var, pool: c.var.pool }); const lookup = extractIdentifier(result.data.q); // Render account from pubkey. @@ -50,7 +52,7 @@ const searchController: AppController = async (c) => { let events: NostrEvent[] = []; if (event) { - events = [event]; + events = await hydrateEvents({ ...c.var, events: [event] }); } events.push(...(await searchEvents(c, { ...result.data, ...pagination, viewerPubkey }, signal))); @@ -145,67 +147,4 @@ function typeToKinds(type: SearchQuery['type']): number[] { } } -/** Resolve a searched value into an event, if applicable. */ -async function lookupEvent(c: AppContext, query: SearchQuery): Promise { - const { relay, signal } = c.var; - const filters = await getLookupFilters(c, query); - - return relay.query(filters, { signal }) - .then((events) => hydrateEvents({ ...c.var, events })) - .then(([event]) => event); -} - -/** Get filters to lookup the input value. */ -async function getLookupFilters(c: AppContext, { q, type, resolve }: SearchQuery): Promise { - const accounts = !type || type === 'accounts'; - const statuses = !type || type === 'statuses'; - - if (!resolve || type === 'hashtags') { - return []; - } - - if (n.id().safeParse(q).success) { - const filters: NostrFilter[] = []; - if (accounts) filters.push({ kinds: [0], authors: [q] }); - if (statuses) filters.push({ kinds: [1, 20], ids: [q] }); - return filters; - } - - const lookup = extractIdentifier(q); - if (!lookup) return []; - - try { - const result = nip19.decode(lookup); - const filters: NostrFilter[] = []; - switch (result.type) { - case 'npub': - if (accounts) filters.push({ kinds: [0], authors: [result.data] }); - break; - case 'nprofile': - if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey] }); - break; - case 'note': - if (statuses) filters.push({ kinds: [1, 20], ids: [result.data] }); - break; - case 'nevent': - if (statuses) filters.push({ kinds: [1, 20], ids: [result.data.id] }); - break; - } - return filters; - } catch { - // fall through - } - - try { - const { pubkey } = await lookupNip05(lookup, c.var); - if (pubkey) { - return [{ kinds: [0], authors: [pubkey] }]; - } - } catch { - // fall through - } - - return []; -} - export { searchController }; diff --git a/packages/ditto/utils/lookup.ts b/packages/ditto/utils/lookup.ts index e0f10a0e..cd3976d6 100644 --- a/packages/ditto/utils/lookup.ts +++ b/packages/ditto/utils/lookup.ts @@ -1,5 +1,5 @@ -import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify'; -import { nip19 } from 'nostr-tools'; +import { NKinds, NostrEvent, NostrFilter, NPool, NRelay, NSchema as n, NStore } from '@nostrify/nostrify'; +import { nip19, sortEvents } from 'nostr-tools'; import { match } from 'path-to-regexp'; import tldts from 'tldts'; @@ -85,13 +85,167 @@ export function extractIdentifier(value: string): string | undefined { value = value.replace(/^@/, ''); - if (n.bech32().safeParse(value).success) { + if (isBech32(value)) { return value; } - const { isIcann, domain } = tldts.parse(value); - - if (isIcann && domain) { + if (isUsername(value)) { return value; } } + +interface LookupEventsOpts { + db: DittoDB; + conf: DittoConf; + pool: NPool; + relay: NStore; + signal?: AbortSignal; +} + +export async function lookupEvent(value: string, opts: LookupEventsOpts): Promise { + const { pool, relay, signal } = opts; + + const identifier = extractIdentifier(value); + if (!identifier) return; + + let result: DittoPointer; + + if (isBech32(identifier)) { + result = bech32ToPointer(identifier); + } else if (isUsername(identifier)) { + result = { type: 'address', pointer: { kind: 0, identifier: '', ...await lookupNip05(identifier, opts) } }; + } else { + throw new Error('Unsupported identifier: neither bech32 nor username'); + } + + const filter = pointerToFilter(result); + const relayUrls = new Set(result.pointer.relays ?? []); + + const [event] = await relay.query([filter], { signal }); + + if (event) { + return event; + } + + let pubkey: string | undefined; + + if (result.type === 'address') { + pubkey = result.pointer.pubkey; + } else if (result.type === 'event') { + pubkey = result.pointer.author; + } + + if (pubkey) { + let [relayList] = await relay.query([{ kinds: [10002], authors: [pubkey] }], { signal }); + + if (!relayList) { + [relayList] = await pool.query([{ kinds: [10002], authors: [pubkey] }], { signal }); + if (relayList) { + await relay.event(relayList); + } + } + + if (relayList) { + for (const relayUrl of getEventRelayUrls(relayList)) { + relayUrls.add(relayUrl); + } + } + } + + const urls = [...relayUrls].slice(0, 5); + + if (result.type === 'address') { + const results = await Promise.all(urls.map((relayUrl) => pool.relay(relayUrl).query([filter], { signal }))); + const [event] = sortEvents(results.flat()); + if (event) { + await relay.event(event, { signal }); + return event; + } + } + + if (result.type === 'event') { + const [event] = await Promise.any(urls.map((relayUrl) => pool.relay(relayUrl).query([filter], { signal }))); + if (event) { + await relay.event(event, { signal }); + return event; + } + } +} + +type DittoPointer = { type: 'event'; pointer: nip19.EventPointer } | { type: 'address'; pointer: nip19.AddressPointer }; + +function bech32ToPointer(bech32: string): DittoPointer { + const decoded = nip19.decode(bech32); + + switch (decoded.type) { + case 'note': + return { type: 'event', pointer: { id: decoded.data } }; + case 'nevent': + return { type: 'event', pointer: decoded.data }; + case 'npub': + return { type: 'address', pointer: { kind: 0, identifier: '', pubkey: decoded.data } }; + case 'nprofile': + return { type: 'address', pointer: { kind: 0, identifier: '', ...decoded.data } }; + case 'naddr': + return { type: 'address', pointer: decoded.data }; + } + + throw new Error('Invalid bech32 pointer'); +} + +function pointerToFilter(pointer: DittoPointer): NostrFilter { + switch (pointer.type) { + case 'event': { + const { id, kind, author } = pointer.pointer; + const filter: NostrFilter = { ids: [id] }; + + if (kind) { + filter.kinds = [kind]; + } + + if (author) { + filter.authors = [author]; + } + + return filter; + } + case 'address': { + const { kind, identifier, pubkey } = pointer.pointer; + const filter: NostrFilter = { kinds: [kind], authors: [pubkey] }; + + if (NKinds.replaceable(kind)) { + filter['#d'] = [identifier]; + } + + return filter; + } + } +} + +function isUsername(value: string): boolean { + const { isIcann, domain } = tldts.parse(value); + return Boolean(isIcann && domain); +} + +function isBech32(value: string): value is `${string}1${string}` { + return n.bech32().safeParse(value).success; +} + +function getEventRelayUrls(event: NostrEvent, marker?: 'read' | 'write'): Set<`wss://${string}`> { + const relays = new Set<`wss://${string}`>(); + + for (const [name, relayUrl, _marker] of event.tags) { + if (name === 'r' && (!marker || !_marker || marker === _marker)) { + try { + const url = new URL(relayUrl); + if (url.protocol === 'wss:') { + relays.add(url.toString() as `wss://${string}`); + } + } catch { + // fallthrough + } + } + } + + return relays; +}