diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index c4fd6721..d55e0c19 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -8,9 +8,9 @@ import { getAuthor, getFollowedPubkeys } from '@/queries.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; import { uploadFile } from '@/utils/upload.ts'; -import { extractBech32, nostrNow } from '@/utils.ts'; +import { nostrNow } from '@/utils.ts'; import { createEvent, paginated, parseBody, updateListEvent } from '@/utils/api.ts'; -import { lookupAccount } from '@/utils/lookup.ts'; +import { extractIdentifier, lookupAccount } from '@/utils/lookup.ts'; import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderRelationship } from '@/views/mastodon/relationships.ts'; @@ -125,11 +125,11 @@ const accountSearchController: AppController = async (c) => { const query = decodeURIComponent(result.data.q); const store = await Storages.search(); - const bech32 = extractBech32(query); - const event = await lookupAccount(bech32 ?? query); + const lookup = extractIdentifier(query); + const event = await lookupAccount(lookup ?? query); - if (!event && bech32) { - const pubkey = bech32ToPubkey(bech32); + if (!event && lookup) { + const pubkey = bech32ToPubkey(lookup); return c.json(pubkey ? [await accountFromPubkey(pubkey)] : []); } diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index 075843f3..ef7f84c7 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -6,14 +6,12 @@ import { AppController } from '@/app.ts'; import { booleanParamSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { bech32ToPubkey, extractBech32 } from '@/utils.ts'; +import { bech32ToPubkey } from '@/utils.ts'; +import { ACCT_REGEX, extractIdentifier } from '@/utils/lookup.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.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(), @@ -34,11 +32,11 @@ const searchController: AppController = async (c) => { } const event = await lookupEvent(result.data, signal); - const bech32 = extractBech32(result.data.q); + const lookup = extractIdentifier(result.data.q); // Render account from pubkey. - if (!event && bech32) { - const pubkey = bech32ToPubkey(bech32); + if (!event && lookup) { + const pubkey = bech32ToPubkey(lookup); return c.json({ accounts: pubkey ? [await accountFromPubkey(pubkey)] : [], statuses: [], @@ -131,11 +129,10 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort return filters; } - const bech32 = extractBech32(q); - - if (bech32) { + const lookup = extractIdentifier(q); + if (lookup) { try { - const result = nip19.decode(bech32); + const result = nip19.decode(lookup); switch (result.type) { case 'npub': if (accounts) filters.push({ kinds: [0], authors: [result.data] }); diff --git a/src/utils.ts b/src/utils.ts index d40b2eb6..e361109d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,5 @@ import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; -import { match } from 'path-to-regexp'; import { z } from 'zod'; /** Get the current time in Nostr format. */ @@ -25,51 +24,6 @@ function bech32ToPubkey(bech32: string): string | undefined { } } -/** Extract a bech32 ID out of a search query string. */ -function extractBech32(value: string): string | undefined { - let bech32: string = value; - - try { - const uri = new URL(value); - switch (uri.protocol) { - // Extract from NIP-19 URI, eg `nostr:npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. - case 'nostr:': - bech32 = uri.pathname; - break; - // Extract from URL, eg `https://njump.me/npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. - case 'http:': - case 'https:': { - const accountUriMatch = match<{ acct: string }>('/users/:acct')(uri.pathname); - const accountUrlMatch = match<{ acct: string }>('/\\@:acct')(uri.pathname); - const statusUriMatch = match<{ acct: string; id: string }>('/users/:acct/statuses/:id')(uri.pathname); - const statusUrlMatch = match<{ acct: string; id: string }>('/\\@:acct/:id')(uri.pathname); - const soapboxMatch = match<{ acct: string; id: string }>('/\\@:acct/posts/:id')(uri.pathname); - const nostrMatch = match<{ bech32: string }>('/:bech32')(uri.pathname); - if (accountUriMatch) { - bech32 = accountUriMatch.params.acct; - } else if (accountUrlMatch) { - bech32 = accountUrlMatch.params.acct; - } else if (statusUriMatch) { - bech32 = nip19.noteEncode(statusUriMatch.params.id); - } else if (statusUrlMatch) { - bech32 = nip19.noteEncode(statusUrlMatch.params.id); - } else if (soapboxMatch) { - bech32 = nip19.noteEncode(soapboxMatch.params.id); - } else if (nostrMatch) { - bech32 = nostrMatch.params.bech32; - } - break; - } - } - } catch { - // do nothing - } - - if (n.bech32().safeParse(bech32).success) { - return bech32; - } -} - interface Nip05 { /** Localpart of the nip05, eg `alex` in `alex@alexgleason.me`. */ local: string | undefined; @@ -134,18 +88,6 @@ function isURL(value: unknown): boolean { return z.string().url().safeParse(value).success; } -export { - bech32ToPubkey, - eventAge, - extractBech32, - findTag, - isNostrId, - isURL, - type Nip05, - nostrDate, - nostrNow, - parseNip05, - sha256, -}; +export { bech32ToPubkey, eventAge, findTag, isNostrId, isURL, type Nip05, nostrDate, nostrNow, parseNip05, sha256 }; export { Time } from '@/utils/time.ts'; diff --git a/src/utils/lookup.ts b/src/utils/lookup.ts index 90b30c2b..afcd384d 100644 --- a/src/utils/lookup.ts +++ b/src/utils/lookup.ts @@ -1,10 +1,15 @@ import { NIP05, NostrEvent, NSchema as n } from '@nostrify/nostrify'; +import { nip19 } from 'nostr-tools'; +import { match } from 'path-to-regexp'; import { getAuthor } from '@/queries.ts'; import { bech32ToPubkey } from '@/utils.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { Stickynotes } from '@soapbox/stickynotes'; +/** Matches NIP-05 names with or without an @ in front. */ +export const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/; + /** Resolve a bech32 or NIP-05 identifier to an account. */ export async function lookupAccount( value: string, @@ -35,3 +40,50 @@ export async function lookupPubkey(value: string, signal?: AbortSignal): Promise } } } + +/** Extract an acct or bech32 identifier out of a URL or of itself. */ +export function extractIdentifier(value: string): string | undefined { + try { + const uri = new URL(value); + switch (uri.protocol) { + // Extract from NIP-19 URI, eg `nostr:npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. + case 'nostr:': + value = uri.pathname; + break; + // Extract from URL, eg `https://njump.me/npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. + case 'http:': + case 'https:': { + const accountUriMatch = match<{ acct: string }>('/users/:acct')(uri.pathname); + const accountUrlMatch = match<{ acct: string }>('/\\@:acct')(uri.pathname); + const statusUriMatch = match<{ acct: string; id: string }>('/users/:acct/statuses/:id')(uri.pathname); + const statusUrlMatch = match<{ acct: string; id: string }>('/\\@:acct/:id')(uri.pathname); + const soapboxMatch = match<{ acct: string; id: string }>('/\\@:acct/posts/:id')(uri.pathname); + const nostrMatch = match<{ bech32: string }>('/:bech32')(uri.pathname); + if (accountUriMatch) { + value = accountUriMatch.params.acct; + } else if (accountUrlMatch) { + value = accountUrlMatch.params.acct; + } else if (statusUriMatch) { + value = nip19.noteEncode(statusUriMatch.params.id); + } else if (statusUrlMatch) { + value = nip19.noteEncode(statusUrlMatch.params.id); + } else if (soapboxMatch) { + value = nip19.noteEncode(soapboxMatch.params.id); + } else if (nostrMatch) { + value = nostrMatch.params.bech32; + } + break; + } + } + } catch { + // do nothing + } + + if (n.bech32().safeParse(value).success) { + return value; + } + + if (ACCT_REGEX.test(value)) { + return value; + } +}