mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29: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 { every } from '@hono/hono/combine';
|
||||||
import { cors } from '@hono/hono/cors';
|
import { cors } from '@hono/hono/cors';
|
||||||
import { serveStatic } from '@hono/hono/deno';
|
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 { cron } from '@/cron.ts';
|
||||||
import { startFirehose } from '@/firehose.ts';
|
import { startFirehose } from '@/firehose.ts';
|
||||||
|
|
@ -167,6 +167,7 @@ export interface AppEnv extends DittoEnv {
|
||||||
/** User's relay. Might filter out unwanted content. */
|
/** User's relay. Might filter out unwanted content. */
|
||||||
relay: NRelay;
|
relay: NRelay;
|
||||||
};
|
};
|
||||||
|
pool?: NPool<NRelay>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,8 +235,9 @@ const socketTokenMiddleware = tokenMiddleware((c) => {
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
'/api/*',
|
'/api/*',
|
||||||
(c, next) => {
|
(c: Context<DittoEnv & { Variables: { pool: NPool<NRelay> } }>, next) => {
|
||||||
c.set('relay', new DittoAPIStore({ relay, pool }));
|
c.set('relay', new DittoAPIStore({ relay, pool }));
|
||||||
|
c.set('pool', pool);
|
||||||
return next();
|
return next();
|
||||||
},
|
},
|
||||||
metricsMiddleware,
|
metricsMiddleware,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
import { paginated, paginatedList } from '@ditto/mastoapi/pagination';
|
import { paginated, paginatedList } from '@ditto/mastoapi/pagination';
|
||||||
import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
|
import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
|
||||||
import { nip19 } from 'nostr-tools';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { AppContext, AppController } from '@/app.ts';
|
import { AppContext, AppController } from '@/app.ts';
|
||||||
import { booleanParamSchema } from '@/schema.ts';
|
import { booleanParamSchema } from '@/schema.ts';
|
||||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||||
import { extractIdentifier, lookupPubkey } from '@/utils/lookup.ts';
|
import { extractIdentifier, lookupEvent, lookupPubkey } from '@/utils/lookup.ts';
|
||||||
import { lookupNip05 } from '@/utils/nip05.ts';
|
|
||||||
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||||
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||||
import { getFollowedPubkeys } from '@/queries.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);
|
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);
|
const lookup = extractIdentifier(result.data.q);
|
||||||
|
|
||||||
// Render account from pubkey.
|
// Render account from pubkey.
|
||||||
|
|
@ -50,7 +52,7 @@ const searchController: AppController = async (c) => {
|
||||||
let events: NostrEvent[] = [];
|
let events: NostrEvent[] = [];
|
||||||
|
|
||||||
if (event) {
|
if (event) {
|
||||||
events = [event];
|
events = await hydrateEvents({ ...c.var, events: [event] });
|
||||||
}
|
}
|
||||||
|
|
||||||
events.push(...(await searchEvents(c, { ...result.data, ...pagination, viewerPubkey }, signal)));
|
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 };
|
export { searchController };
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify';
|
import { NKinds, NostrEvent, NostrFilter, NPool, NRelay, NSchema as n, NStore } from '@nostrify/nostrify';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19, sortEvents } from 'nostr-tools';
|
||||||
import { match } from 'path-to-regexp';
|
import { match } from 'path-to-regexp';
|
||||||
import tldts from 'tldts';
|
import tldts from 'tldts';
|
||||||
|
|
||||||
|
|
@ -85,13 +85,167 @@ export function extractIdentifier(value: string): string | undefined {
|
||||||
|
|
||||||
value = value.replace(/^@/, '');
|
value = value.replace(/^@/, '');
|
||||||
|
|
||||||
if (n.bech32().safeParse(value).success) {
|
if (isBech32(value)) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
const { isIcann, domain } = tldts.parse(value);
|
||||||
|
return Boolean(isIcann && domain);
|
||||||
|
}
|
||||||
|
|
||||||
if (isIcann && domain) {
|
function isBech32(value: string): value is `${string}1${string}` {
|
||||||
return value;
|
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