mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 03:19:46 +00:00
Merge branch 'lookup' into 'main'
Look up identifiers in search on remote relays See merge request soapbox-pub/ditto!714
This commit is contained in:
commit
1c29b402d9
3 changed files with 171 additions and 76 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue