diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index 674d9f11..1adc2667 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -1,24 +1,38 @@ import { AppController } from '@/app.ts'; import * as eventsDB from '@/db/events.ts'; -import { type Event, nip05, nip19 } from '@/deps.ts'; +import { type Event, type Filter, nip19, z } from '@/deps.ts'; import * as mixer from '@/mixer.ts'; import { lookupNip05Cached } from '@/nip05.ts'; -import { getAuthor } from '@/queries.ts'; +import { booleanParamSchema } from '@/schema.ts'; import { toAccount, toStatus } from '@/transformers/nostr-to-mastoapi.ts'; -import { bech32ToPubkey, dedupeEvents, Time } from '@/utils.ts'; -import { paginationSchema } from '@/utils/web.ts'; +import { dedupeEvents, Time } from '@/utils.ts'; + +/** Matches NIP-05 names with or without an @ in front. */ +const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/; + +const searchQuerySchema = z.object({ + q: z.string().transform(decodeURIComponent), + type: z.enum(['accounts', 'statuses', 'hashtags']).optional(), + resolve: booleanParamSchema.optional().transform(Boolean), + following: z.boolean().default(false), + account_id: z.string().optional(), + limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)), +}); + +type SearchQuery = z.infer; const searchController: AppController = async (c) => { - const q = c.req.query('q'); - const params = paginationSchema.parse(c.req.query()); + const result = searchQuerySchema.safeParse(c.req.query()); - if (!q) { - return c.json({ error: 'Missing `q` query parameter.' }, 422); + if (!result.success) { + return c.json({ error: 'Bad request', schema: result.error }, 422); } + const { q, type, limit } = result.data; + const [event, events] = await Promise.all([ - lookupEvent(decodeURIComponent(q)), - eventsDB.getFilters([{ kinds: [1], search: q, ...params }]), + lookupEvent(result.data), + !type || type === 'statuses' ? eventsDB.getFilters([{ kinds: [1], search: q, limit }]) : [] as Event[], ]); if (event) { @@ -27,17 +41,18 @@ const searchController: AppController = async (c) => { const results = dedupeEvents(events); - const accounts = await Promise.all( - results - .filter((event): event is Event<0> => event.kind === 0) - .map((event) => toAccount(event)), - ); - - const statuses = await Promise.all( - results - .filter((event): event is Event<1> => event.kind === 1) - .map((event) => toStatus(event, c.get('pubkey'))), - ); + const [accounts, statuses] = await Promise.all([ + Promise.all( + results + .filter((event): event is Event<0> => event.kind === 0) + .map((event) => toAccount(event)), + ), + Promise.all( + results + .filter((event): event is Event<1> => event.kind === 1) + .map((event) => toStatus(event, c.get('pubkey'))), + ), + ]); return c.json({ accounts: accounts.filter(Boolean), @@ -47,24 +62,62 @@ const searchController: AppController = async (c) => { }; /** Resolve a searched value into an event, if applicable. */ -async function lookupEvent(value: string): Promise | undefined> { - if (new RegExp(`^${nip19.BECH32_REGEX.source}$`).test(value)) { - const pubkey = bech32ToPubkey(value); - if (pubkey) { - return getAuthor(pubkey); +async function lookupEvent(query: SearchQuery): Promise { + const filters = await getLookupFilters(query); + const [event] = await mixer.getFilters(filters, { limit: 1, timeout: Time.seconds(1) }); + return event; +} + +/** Get filters to lookup the input value. */ +async function getLookupFilters({ q, type, resolve }: SearchQuery): Promise { + const filters: Filter[] = []; + + if (!resolve || type === 'hashtags') { + return filters; + } + + if (new RegExp(`^${nip19.BECH32_REGEX.source}$`).test(q)) { + try { + const result = nip19.decode(q); + switch (result.type) { + case 'npub': + filters.push({ kinds: [0], authors: [result.data] }); + break; + case 'nprofile': + filters.push({ kinds: [0], authors: [result.data.pubkey] }); + break; + case 'note': + filters.push({ kinds: [1], ids: [result.data] }); + break; + case 'nevent': + filters.push({ kinds: [1], ids: [result.data.id] }); + break; + } + } catch (_e) { + // do nothing } - } else if (/^[0-9a-f]{64}$/.test(value)) { - const [event] = await mixer.getFilters( - [{ kinds: [0], authors: [value], limit: 1 }, { kinds: [1], ids: [value], limit: 1 }], - { limit: 1, timeout: Time.seconds(1) }, - ); - return event; - } else if (nip05.NIP05_REGEX.test(value)) { - const pubkey = await lookupNip05Cached(value); + } else if (/^[0-9a-f]{64}$/.test(q)) { + filters.push({ kinds: [0], authors: [q] }); + filters.push({ kinds: [1], ids: [q] }); + } else if ((!type || type === 'accounts') && ACCT_REGEX.test(q)) { + const pubkey = await lookupNip05Cached(q); if (pubkey) { - return getAuthor(pubkey); + filters.push({ kinds: [0], authors: [pubkey] }); } } + + if (!type) { + return filters; + } + + return filters.filter(({ kinds }) => { + switch (type) { + case 'accounts': + return kinds?.every((kind) => kind === 0); + case 'statuses': + return kinds?.every((kind) => kind === 1); + } + }); } export { searchController }; diff --git a/src/mixer.ts b/src/mixer.ts index 9d189397..c6788ffa 100644 --- a/src/mixer.ts +++ b/src/mixer.ts @@ -11,6 +11,8 @@ async function getFilters( filters: DittoFilter[], opts?: GetFiltersOpts, ): Promise[]> { + if (!filters.length) return Promise.resolve([]); + const results = await Promise.allSettled([ client.getFilters(filters.filter((filter) => !filter.local), opts), eventsDB.getFilters(filters, opts),