Look up identifiers in search on remote relays

This commit is contained in:
Alex Gleason 2025-03-08 18:28:32 -06:00
parent 478debfda1
commit f01b4c0791
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
3 changed files with 171 additions and 76 deletions

View file

@ -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<NRelay>;
};
}
@ -234,8 +235,9 @@ const socketTokenMiddleware = tokenMiddleware((c) => {
app.use(
'/api/*',
(c, next) => {
(c: Context<DittoEnv & { Variables: { pool: NPool<NRelay> } }>, next) => {
c.set('relay', new DittoAPIStore({ relay, pool }));
c.set('pool', pool);
return next();
},
metricsMiddleware,

View file

@ -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<NostrEvent | undefined> {
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<NostrFilter[]> {
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 };

View file

@ -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<NRelay>;
relay: NStore;
signal?: AbortSignal;
}
export async function lookupEvent(value: string, opts: LookupEventsOpts): Promise<NostrEvent | undefined> {
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<string>(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;
}