Merge branch 'nip50-domain' into 'main'

NIP-50 domain search

See merge request soapbox-pub/ditto!117
This commit is contained in:
Alex Gleason 2024-03-20 17:57:46 +00:00
commit 84784cd46b
22 changed files with 174 additions and 114 deletions

View File

@ -10,7 +10,7 @@
"relays:sync": "deno run -A --unstable-ffi scripts/relays.ts sync"
},
"exclude": ["./public"],
"imports": { "@/": "./src/", "@soapbox/nspec": "jsr:@soapbox/nspec@^0.7.0", "~/fixtures/": "./fixtures/" },
"imports": { "@/": "./src/", "@soapbox/nspec": "jsr:@soapbox/nspec@^0.8.1", "~/fixtures/": "./fixtures/" },
"lint": {
"include": ["src/", "scripts/"],
"rules": {

View File

@ -1,3 +1,4 @@
import { NostrFilter } from '@soapbox/nspec';
import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { insertUser } from '@/db/users.ts';
@ -8,13 +9,13 @@ import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { eventsDB } from '@/storages.ts';
import { addTag, deleteTag, findReplyTag, getTagSet } from '@/tags.ts';
import { uploadFile } from '@/upload.ts';
import { lookupAccount, nostrNow } from '@/utils.ts';
import { nostrNow } from '@/utils.ts';
import { createEvent, paginated, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts';
import { 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';
import { renderStatus } from '@/views/mastodon/statuses.ts';
import { DittoFilter } from '@/interfaces/DittoFilter.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
const usernameSchema = z
@ -145,7 +146,7 @@ const accountStatusesController: AppController = async (c) => {
}
}
const filter: DittoFilter = {
const filter: NostrFilter = {
authors: [pubkey],
kinds: [1],
since,

View File

@ -1,6 +1,6 @@
import { NostrFilter } from '@soapbox/nspec';
import { AppController } from '@/app.ts';
import { nip19, type NostrEvent, z } from '@/deps.ts';
import { type DittoFilter } from '@/interfaces/DittoFilter.ts';
import { booleanParamSchema } from '@/schema.ts';
import { nostrIdSchema } from '@/schemas/nostr.ts';
import { searchStore } from '@/storages.ts';
@ -67,7 +67,7 @@ const searchController: AppController = async (c) => {
function searchEvents({ q, type, limit, account_id }: SearchQuery, signal: AbortSignal): Promise<NostrEvent[]> {
if (type === 'hashtags') return Promise.resolve([]);
const filter: DittoFilter = {
const filter: NostrFilter = {
kinds: typeToKinds(type),
search: q,
limit,
@ -107,8 +107,8 @@ async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<Nos
}
/** Get filters to lookup the input value. */
async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: AbortSignal): Promise<DittoFilter[]> {
const filters: DittoFilter[] = [];
async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: AbortSignal): Promise<NostrFilter[]> {
const filters: NostrFilter[] = [];
const accounts = !type || type === 'accounts';
const statuses = !type || type === 'statuses';

View File

@ -1,6 +1,7 @@
import { NostrFilter } from '@soapbox/nspec';
import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { Debug, z } from '@/deps.ts';
import { DittoFilter } from '@/interfaces/DittoFilter.ts';
import { getAuthor, getFeedPubkeys } from '@/queries.ts';
import { Sub } from '@/subs.ts';
import { bech32ToPubkey } from '@/utils.ts';
@ -82,17 +83,19 @@ async function topicToFilter(
topic: Stream,
query: Record<string, string>,
pubkey: string | undefined,
): Promise<DittoFilter | undefined> {
): Promise<NostrFilter | undefined> {
const { host } = Conf.url;
switch (topic) {
case 'public':
return { kinds: [1] };
case 'public:local':
return { kinds: [1], local: true };
return { kinds: [1], search: `domain:${host}` };
case 'hashtag':
if (query.tag) return { kinds: [1], '#t': [query.tag] };
break;
case 'hashtag:local':
if (query.tag) return { kinds: [1], '#t': [query.tag], local: true };
if (query.tag) return { kinds: [1], '#t': [query.tag], search: `domain:${host}` };
break;
case 'user':
// HACK: this puts the user's entire contacts list into RAM,

View File

@ -1,6 +1,7 @@
import { NostrFilter } from '@soapbox/nspec';
import { type AppContext, type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { z } from '@/deps.ts';
import { type DittoFilter } from '@/interfaces/DittoFilter.ts';
import { getFeedPubkeys } from '@/queries.ts';
import { booleanParamSchema } from '@/schema.ts';
import { eventsDB } from '@/storages.ts';
@ -22,7 +23,15 @@ const publicQuerySchema = z.object({
const publicTimelineController: AppController = (c) => {
const params = paginationSchema.parse(c.req.query());
const { local } = publicQuerySchema.parse(c.req.query());
return renderStatuses(c, [{ kinds: [1], local, ...params }]);
const { host } = Conf.url;
const filter: NostrFilter = { kinds: [1], ...params };
if (local) {
filter.search = `domain:${host}`;
}
return renderStatuses(c, [filter]);
};
const hashtagTimelineController: AppController = (c) => {
@ -32,7 +41,7 @@ const hashtagTimelineController: AppController = (c) => {
};
/** Render statuses for timelines. */
async function renderStatuses(c: AppContext, filters: DittoFilter[]) {
async function renderStatuses(c: AppContext, filters: NostrFilter[]) {
const { signal } = c.req.raw;
const events = await eventsDB

View File

@ -4,9 +4,11 @@ import type { AppController } from '@/app.ts';
/** Landing page controller. */
const indexController: AppController = (c) => {
const { origin } = Conf.url;
return c.text(`Please connect with a Mastodon client:
${Conf.localDomain}
${origin}
Ditto <https://gitlab.com/soapbox-pub/ditto>
`);

View File

@ -14,6 +14,7 @@ interface DittoDB {
unattached_media: UnattachedMediaRow;
author_stats: AuthorStatsRow;
event_stats: EventStatsRow;
pubkey_domains: PubkeyDomainRow;
}
interface AuthorStatsRow {
@ -66,6 +67,11 @@ interface UnattachedMediaRow {
uploaded_at: Date;
}
interface PubkeyDomainRow {
pubkey: string;
domain: string;
}
const sqliteWorker = new SqliteWorker();
await sqliteWorker.open(Conf.dbPath);

View File

@ -0,0 +1,21 @@
import { Kysely } from '@/deps.ts';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('pubkey_domains')
.ifNotExists()
.addColumn('pubkey', 'text', (col) => col.primaryKey())
.addColumn('domain', 'text')
.execute();
await db.schema
.createIndex('pubkey_domains_domain_index')
.on('pubkey_domains')
.column('domain')
.ifNotExists()
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('pubkey_domains').execute();
}

View File

@ -1,7 +1,4 @@
import { Conf } from '@/config.ts';
import { matchFilters, type NostrEvent, type NostrFilter, stringifyStable, z } from '@/deps.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { type DittoFilter } from '@/interfaces/DittoFilter.ts';
import { type NostrEvent, type NostrFilter, stringifyStable, z } from '@/deps.ts';
import { isReplaceableKind } from '@/kinds.ts';
import { nostrIdSchema } from '@/schemas/nostr.ts';
@ -12,28 +9,6 @@ type AuthorMicrofilter = { kinds: [0]; authors: [NostrEvent['pubkey']] };
/** Filter to get one specific event. */
type MicroFilter = IdMicrofilter | AuthorMicrofilter;
function matchDittoFilter(filter: DittoFilter, event: DittoEvent): boolean {
if (filter.local && !(event.user || event.pubkey === Conf.pubkey)) {
return false;
}
return matchFilters([filter], event);
}
/**
* Similar to nostr-tools `matchFilters`, but supports Ditto's custom keys.
* Database calls are needed to look up the extra data, so it's passed in as an argument.
*/
function matchDittoFilters(filters: DittoFilter[], event: DittoEvent): boolean {
for (const filter of filters) {
if (matchDittoFilter(filter, event)) {
return true;
}
}
return false;
}
/** Get deterministic ID for a microfilter. */
function getFilterId(filter: MicroFilter): string {
if ('ids' in filter) {
@ -114,7 +89,6 @@ export {
getMicroFilters,
type IdMicrofilter,
isMicrofilter,
matchDittoFilters,
type MicroFilter,
normalizeFilters,
};

View File

@ -1,12 +1,6 @@
import { type NostrEvent, type NostrFilter } from '@/deps.ts';
import { type NostrEvent } from '@/deps.ts';
import { type DittoEvent } from './DittoEvent.ts';
/** Additional properties that may be added by Ditto to events. */
export type DittoRelation = Exclude<keyof DittoEvent, keyof NostrEvent>;
/** Custom filter interface that extends Nostr filters with extra options for Ditto. */
export interface DittoFilter extends NostrFilter {
/** Whether the event was authored by a local user. */
local?: boolean;
}

View File

@ -3,13 +3,13 @@ import { Conf } from '@/config.ts';
const csp = (): AppMiddleware => {
return async (c, next) => {
const { host, protocol } = Conf.url;
const { host, protocol, origin } = Conf.url;
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
const policies = [
'upgrade-insecure-requests',
`script-src 'self'`,
`connect-src 'self' blob: ${Conf.localDomain} ${wsProtocol}//${host}`,
`connect-src 'self' blob: ${origin} ${wsProtocol}//${host}`,
`media-src 'self' https:`,
`img-src 'self' data: blob: https:`,
`default-src 'none'`,

View File

@ -1,4 +1,6 @@
import { NSchema as n } from '@soapbox/nspec';
import { Conf } from '@/config.ts';
import { db } from '@/db.ts';
import { addRelays } from '@/db/relays.ts';
import { deleteAttachedMedia } from '@/db/unattached-media.ts';
import { Debug, LNURL, type NostrEvent } from '@/deps.ts';
@ -15,6 +17,7 @@ import { TrendsWorker } from '@/workers/trends.ts';
import { verifyEventWorker } from '@/workers/verify.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { lnurlCache } from '@/utils/lnurl.ts';
import { nip05Cache } from '@/utils/nip05.ts';
const debug = Debug('ditto:pipeline');
@ -30,6 +33,7 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise<void
await Promise.all([
storeEvent(event, signal),
parseMetadata(event, signal),
processDeletions(event, signal),
trackRelays(event),
trackHashtags(event),
@ -74,6 +78,35 @@ async function storeEvent(event: DittoEvent, signal?: AbortSignal): Promise<void
}
}
/** Parse kind 0 metadata and track indexes in the database. */
async function parseMetadata(event: NostrEvent, signal: AbortSignal): Promise<void> {
if (event.kind !== 0) return;
// Parse metadata.
const metadata = n.json().pipe(n.metadata()).safeParse(event.content);
if (!metadata.success) return;
// Get nip05.
const { nip05 } = metadata.data;
if (!nip05) return;
// Fetch nip05.
const result = await nip05Cache.fetch(nip05, { signal }).catch(() => undefined);
if (!result) return;
// Ensure pubkey matches event.
const { pubkey } = result;
if (pubkey !== event.pubkey) return;
// Track pubkey domain.
const [, domain] = nip05.split('@');
await db
.insertInto('pubkey_domains')
.values({ pubkey, domain })
.execute()
.catch(debug);
}
/** Query to-be-deleted events, ensure their pubkey matches, then delete them from the database. */
async function processDeletions(event: NostrEvent, signal: AbortSignal): Promise<void> {
if (event.kind === 5) {

View File

@ -1,3 +1,4 @@
import { Conf } from '@/config.ts';
import { eventsDB, optimizer } from '@/storages.ts';
import { Debug, type NostrEvent, type NostrFilter } from '@/deps.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
@ -89,7 +90,13 @@ function getDescendants(eventId: string, signal = AbortSignal.timeout(2000)): Pr
/** Returns whether the pubkey is followed by a local user. */
async function isLocallyFollowed(pubkey: string): Promise<boolean> {
const [event] = await eventsDB.query([{ kinds: [3], '#p': [pubkey], local: true, limit: 1 }], { limit: 1 });
const { host } = Conf.url;
const [event] = await eventsDB.query(
[{ kinds: [3], '#p': [pubkey], search: `domain:${host}`, limit: 1 }],
{ limit: 1 },
);
return Boolean(event);
}

View File

@ -1,5 +1,4 @@
import { db } from '@/db.ts';
import { buildUserEvent } from '@/db/users.ts';
import { assertEquals, assertRejects } from '@/deps-test.ts';
import event0 from '~/fixtures/events/event-0.json' with { type: 'json' };
@ -28,23 +27,20 @@ Deno.test('insert and filter events', async () => {
);
});
Deno.test('query events with local filter', async () => {
Deno.test('query events with domain search filter', async () => {
await eventsDB.event(event1);
assertEquals(await eventsDB.query([{}]), [event1]);
assertEquals(await eventsDB.query([{ local: true }]), []);
assertEquals(await eventsDB.query([{ local: false }]), [event1]);
assertEquals(await eventsDB.query([{ search: 'domain:localhost:8000' }]), []);
assertEquals(await eventsDB.query([{ search: '' }]), [event1]);
const userEvent = await buildUserEvent({
username: 'alex',
pubkey: event1.pubkey,
inserted_at: new Date(),
admin: false,
});
await eventsDB.event(userEvent);
await db
.insertInto('pubkey_domains')
.values({ pubkey: event1.pubkey, domain: 'localhost:8000' })
.execute();
assertEquals(await eventsDB.query([{ kinds: [1], local: true }]), [event1]);
assertEquals(await eventsDB.query([{ kinds: [1], local: false }]), []);
assertEquals(await eventsDB.query([{ kinds: [1], search: 'domain:localhost:8000' }]), [event1]);
assertEquals(await eventsDB.query([{ kinds: [1], search: 'domain:example.com' }]), []);
});
Deno.test('delete events', async () => {

View File

@ -1,9 +1,9 @@
import { NIP50, NostrFilter } from '@soapbox/nspec';
import { Conf } from '@/config.ts';
import { type DittoDB } from '@/db.ts';
import { Debug, Kysely, type NostrEvent, type NStore, type NStoreOpts, type SelectQueryBuilder } from '@/deps.ts';
import { normalizeFilters } from '@/filter.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { type DittoFilter } from '@/interfaces/DittoFilter.ts';
import { isDittoInternalKind, isParameterizedReplaceableKind, isReplaceableKind } from '@/kinds.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { purifyEvent } from '@/storages/hydrate.ts';
@ -143,7 +143,7 @@ class EventsDB implements NStore {
}
/** Build the query for a filter. */
getFilterQuery(db: Kysely<DittoDB>, filter: DittoFilter): EventQuery {
getFilterQuery(db: Kysely<DittoDB>, filter: NostrFilter): EventQuery {
let query = db
.selectFrom('events')
.select([
@ -161,7 +161,7 @@ class EventsDB implements NStore {
for (const [key, value] of Object.entries(filter)) {
if (value === undefined) continue;
switch (key as keyof DittoFilter) {
switch (key as keyof NostrFilter) {
case 'ids':
query = query.where('events.id', 'in', filter.ids!);
break;
@ -192,23 +192,35 @@ class EventsDB implements NStore {
}
}
if (typeof filter.local === 'boolean') {
query = query
.leftJoin(() => this.usersQuery(), (join) => join.onRef('users.d_tag', '=', 'events.pubkey'))
.where('users.d_tag', filter.local ? 'is not' : 'is', null);
}
if (filter.search) {
query = query
.innerJoin('events_fts', 'events_fts.id', 'events.id')
.where('events_fts.content', 'match', JSON.stringify(filter.search));
const tokens = NIP50.parseInput(filter.search);
const domain = (tokens.find((t) =>
typeof t === 'object' && t.key === 'domain'
) as { key: 'domain'; value: string } | undefined)?.value;
if (domain) {
query = query
.innerJoin('pubkey_domains', 'pubkey_domains.pubkey', 'events.pubkey')
.where('pubkey_domains.domain', '=', domain);
}
const q = tokens.filter((t) =>
typeof t === 'string'
).join(' ');
if (q) {
query = query
.innerJoin('events_fts', 'events_fts.id', 'events.id')
.where('events_fts.content', 'match', JSON.stringify(q));
}
}
return query;
}
/** Combine filter queries into a single union query. */
getEventsQuery(filters: DittoFilter[]) {
getEventsQuery(filters: NostrFilter[]) {
return filters
.map((filter) => this.#db.selectFrom(() => this.getFilterQuery(this.#db, filter).as('events')).selectAll())
.reduce((result, query) => result.unionAll(query));
@ -224,7 +236,7 @@ class EventsDB implements NStore {
}
/** Get events for filters from the database. */
async query(filters: DittoFilter[], opts: NStoreOpts = {}): Promise<DittoEvent[]> {
async query(filters: NostrFilter[], opts: NStoreOpts = {}): Promise<DittoEvent[]> {
filters = normalizeFilters(filters); // Improves performance of `{ kinds: [0], authors: ['...'] }` queries.
if (opts.signal?.aborted) return Promise.resolve([]);
@ -281,7 +293,7 @@ class EventsDB implements NStore {
}
/** Delete events from each table. Should be run in a transaction! */
async deleteEventsTrx(db: Kysely<DittoDB>, filters: DittoFilter[]) {
async deleteEventsTrx(db: Kysely<DittoDB>, filters: NostrFilter[]) {
if (!filters.length) return Promise.resolve();
this.#debug('DELETE', JSON.stringify(filters));
@ -294,7 +306,7 @@ class EventsDB implements NStore {
}
/** Delete events based on filters from the database. */
async remove(filters: DittoFilter[], _opts?: NStoreOpts): Promise<void> {
async remove(filters: NostrFilter[], _opts?: NStoreOpts): Promise<void> {
if (!filters.length) return Promise.resolve();
this.#debug('DELETE', JSON.stringify(filters));
@ -302,7 +314,7 @@ class EventsDB implements NStore {
}
/** Get number of events that would be returned by filters. */
async count(filters: DittoFilter[], opts: NStoreOpts = {}): Promise<{ count: number; approximate: boolean }> {
async count(filters: NostrFilter[], opts: NStoreOpts = {}): Promise<{ count: number; approximate: boolean }> {
if (opts.signal?.aborted) return Promise.reject(abortError());
if (!filters.length) return Promise.resolve({ count: 0, approximate: false });

View File

@ -1,7 +1,7 @@
import { NostrFilter } from '@soapbox/nspec';
import { Debug, NSet, type NStore, type NStoreOpts } from '@/deps.ts';
import { normalizeFilters } from '@/filter.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { type DittoFilter } from '@/interfaces/DittoFilter.ts';
import { abortError } from '@/utils/abort.ts';
interface OptimizerOpts {
@ -32,7 +32,7 @@ class Optimizer implements NStore {
]);
}
async query(filters: DittoFilter[], opts: NStoreOpts = {}): Promise<DittoEvent[]> {
async query(filters: NostrFilter[], opts: NStoreOpts = {}): Promise<DittoEvent[]> {
if (opts?.signal?.aborted) return Promise.reject(abortError());
filters = normalizeFilters(filters);

View File

@ -1,8 +1,7 @@
import { NRelay1 } from '@soapbox/nspec';
import { NostrFilter, NRelay1 } from '@soapbox/nspec';
import { Debug, type NostrEvent, type NStore, type NStoreOpts } from '@/deps.ts';
import { normalizeFilters } from '@/filter.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { type DittoFilter } from '@/interfaces/DittoFilter.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { abortError } from '@/utils/abort.ts';
@ -32,7 +31,7 @@ class SearchStore implements NStore {
return Promise.reject(new Error('EVENT not implemented.'));
}
async query(filters: DittoFilter[], opts?: NStoreOpts): Promise<DittoEvent[]> {
async query(filters: NostrFilter[], opts?: NStoreOpts): Promise<DittoEvent[]> {
filters = normalizeFilters(filters);
if (opts?.signal?.aborted) return Promise.reject(abortError());

View File

@ -1,6 +1,6 @@
import { NostrFilter } from '@soapbox/nspec';
import { Debug } from '@/deps.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { type DittoFilter } from '@/interfaces/DittoFilter.ts';
import { Subscription } from '@/subscription.ts';
const debug = Debug('ditto:subs');
@ -21,7 +21,7 @@ class SubscriptionStore {
* }
* ```
*/
sub(socket: unknown, id: string, filters: DittoFilter[]): Subscription {
sub(socket: unknown, id: string, filters: NostrFilter[]): Subscription {
debug('sub', id, JSON.stringify(filters));
let subs = this.#store.get(socket);

View File

@ -1,13 +1,12 @@
import { Machina, type NostrEvent } from '@/deps.ts';
import { matchDittoFilters } from '@/filter.ts';
import { type DittoFilter } from '@/interfaces/DittoFilter.ts';
import { NostrFilter } from '@soapbox/nspec';
import { Machina, matchFilters, type NostrEvent } from '@/deps.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
class Subscription implements AsyncIterable<NostrEvent> {
filters: DittoFilter[];
filters: NostrFilter[];
#machina: Machina<NostrEvent>;
constructor(filters: DittoFilter[]) {
constructor(filters: NostrFilter[]) {
this.filters = filters;
this.#machina = new Machina();
}
@ -17,7 +16,8 @@ class Subscription implements AsyncIterable<NostrEvent> {
}
matches(event: DittoEvent): boolean {
return matchDittoFilters(this.filters, event);
// TODO: Match `search` field.
return matchFilters(this.filters, event);
}
close() {

View File

@ -1,6 +1,4 @@
import { type EventTemplate, getEventHash, nip19, type NostrEvent, z } from '@/deps.ts';
import { getAuthor } from '@/queries.ts';
import { nip05Cache } from '@/utils/nip05.ts';
import { nostrIdSchema } from '@/schemas/nostr.ts';
/** Get the current time in Nostr format. */
@ -55,18 +53,6 @@ function parseNip05(value: string): Nip05 {
};
}
/** Resolve a bech32 or NIP-05 identifier to an account. */
async function lookupAccount(value: string, signal = AbortSignal.timeout(3000)): Promise<NostrEvent | undefined> {
console.log(`Looking up ${value}`);
const pubkey = bech32ToPubkey(value) ||
await nip05Cache.fetch(value, { signal }).then(({ pubkey }) => pubkey).catch(() => undefined);
if (pubkey) {
return getAuthor(pubkey);
}
}
/** Return the event's age in milliseconds. */
function eventAge(event: NostrEvent): number {
return Date.now() - nostrDate(event.created_at).getTime();
@ -153,7 +139,6 @@ export {
isNostrId,
isRelay,
isURL,
lookupAccount,
type Nip05,
nostrDate,
nostrNow,

View File

@ -128,10 +128,10 @@ function buildLinkHeader(url: string, events: NostrEvent[]): string | undefined
const firstEvent = events[0];
const lastEvent = events[events.length - 1];
const { localDomain } = Conf;
const { origin } = Conf.url;
const { pathname, search } = new URL(url);
const next = new URL(pathname + search, localDomain);
const prev = new URL(pathname + search, localDomain);
const next = new URL(pathname + search, origin);
const prev = new URL(pathname + search, origin);
next.searchParams.set('until', String(lastEvent.created_at));
prev.searchParams.set('since', String(firstEvent.created_at));

18
src/utils/lookup.ts Normal file
View File

@ -0,0 +1,18 @@
import { type NostrEvent } from '@/deps.ts';
import { getAuthor } from '@/queries.ts';
import { bech32ToPubkey } from '@/utils.ts';
import { nip05Cache } from '@/utils/nip05.ts';
/** Resolve a bech32 or NIP-05 identifier to an account. */
async function lookupAccount(value: string, signal = AbortSignal.timeout(3000)): Promise<NostrEvent | undefined> {
console.log(`Looking up ${value}`);
const pubkey = bech32ToPubkey(value) ||
await nip05Cache.fetch(value, { signal }).then(({ pubkey }) => pubkey).catch(() => undefined);
if (pubkey) {
return getAuthor(pubkey);
}
}
export { lookupAccount };