Fix NIP05 verification

This commit is contained in:
Alex Gleason 2025-02-09 13:27:05 -06:00
parent 48507b7505
commit 8c60a4842b
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
3 changed files with 65 additions and 136 deletions

View file

@ -1,49 +1,25 @@
import { Semaphore } from '@lambdalisue/async'; import { Semaphore } from '@lambdalisue/async';
import { NSchema as n } from '@nostrify/nostrify';
import { updateAuthorData } from '@/pipeline.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { faviconCache } from '@/utils/favicon.ts'; import { NostrEvent } from '@nostrify/nostrify';
import { nip05Cache } from '@/utils/nip05.ts';
const kysely = await Storages.kysely(); const kysely = await Storages.kysely();
const sem = new Semaphore(5); const sem = new Semaphore(5);
const query = kysely const query = kysely
.selectFrom('nostr_events') .selectFrom('nostr_events')
.select('content') .select(['id', 'kind', 'content', 'pubkey', 'tags', 'created_at', 'sig'])
.where('kind', '=', 0); .where('kind', '=', 0);
for await (const { content } of query.stream(100)) { for await (const row of query.stream(100)) {
while (sem.locked) { while (sem.locked) {
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
} }
sem.lock(async () => { sem.lock(async () => {
const signal = AbortSignal.timeout(30_000); // generous timeout const event: NostrEvent = { ...row, created_at: Number(row.created_at) };
await updateAuthorData(event, AbortSignal.timeout(3000));
// Parse metadata.
const metadata = n.json().pipe(n.metadata()).catch({}).safeParse(content);
if (!metadata.success) return;
// Update nip05.
const { nip05 } = metadata.data;
if (nip05) {
try {
await nip05Cache.fetch(nip05, { signal });
} catch {
// Ignore.
}
}
// Update favicon.
const domain = nip05?.split('@')[1].toLowerCase();
if (domain) {
try {
await faviconCache.fetch(domain, { signal });
} catch {
// Ignore.
}
}
}); });
} }

View file

@ -1,6 +1,7 @@
import { NKinds, NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { NKinds, NostrEvent, NSchema as n } from '@nostrify/nostrify';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import { Kysely } from 'kysely'; import { Kysely, UpdateObject } from 'kysely';
import tldts from 'tldts';
import { z } from 'zod'; import { z } from 'zod';
import { pipelineEncounters } from '@/caches/pipelineEncounters.ts'; import { pipelineEncounters } from '@/caches/pipelineEncounters.ts';
@ -120,7 +121,7 @@ async function handleEvent(event: DittoEvent, opts: PipelineOpts): Promise<void>
// This needs to run in steps, and should not block the API from responding. // This needs to run in steps, and should not block the API from responding.
Promise.allSettled([ Promise.allSettled([
handleZaps(kysely, event), handleZaps(kysely, event),
parseMetadata(event, opts.signal), updateAuthorData(event, opts.signal),
generateSetEvents(event), generateSetEvents(event),
]) ])
.then(() => .then(() =>
@ -190,18 +191,47 @@ async function storeEvent(event: NostrEvent, signal?: AbortSignal): Promise<unde
} }
/** Parse kind 0 metadata and track indexes in the database. */ /** Parse kind 0 metadata and track indexes in the database. */
async function parseMetadata(event: NostrEvent, signal: AbortSignal): Promise<void> { async function updateAuthorData(event: NostrEvent, signal: AbortSignal): Promise<void> {
if (event.kind !== 0) return; if (event.kind !== 0) return;
// Parse metadata. // Parse metadata.
const metadata = n.json().pipe(n.metadata()).catch({}).safeParse(event.content); const metadata = n.json().pipe(n.metadata()).catch({}).safeParse(event.content);
if (!metadata.success) return; if (!metadata.success) return;
const { name, nip05 } = metadata.data;
const kysely = await Storages.kysely(); const kysely = await Storages.kysely();
// Get nip05. const updates: UpdateObject<DittoTables, 'author_stats'> = {};
const { name, nip05 } = metadata.data;
const result = nip05 ? await nip05Cache.fetch(nip05, { signal }).catch(() => undefined) : undefined; const authorStats = await kysely
.selectFrom('author_stats')
.selectAll()
.where('pubkey', '=', event.pubkey)
.executeTakeFirst();
const lastVerified = authorStats?.nip05_last_verified_at;
const eventNewer = !lastVerified || event.created_at > lastVerified;
if (nip05 !== authorStats?.nip05 && eventNewer) {
if (nip05) {
const tld = tldts.parse(nip05);
if (tld.isIcann && !tld.isIp && !tld.isPrivate) {
const pointer = await nip05Cache.fetch(nip05.toLowerCase(), { signal });
if (pointer.pubkey === event.pubkey) {
updates.nip05 = nip05;
updates.nip05_domain = tld.domain;
updates.nip05_hostname = tld.hostname;
updates.nip05_last_verified_at = event.created_at;
}
}
} else {
updates.nip05 = null;
updates.nip05_domain = null;
updates.nip05_hostname = null;
updates.nip05_last_verified_at = event.created_at;
}
}
// Fetch favicon. // Fetch favicon.
const domain = nip05?.split('@')[1].toLowerCase(); const domain = nip05?.split('@')[1].toLowerCase();
@ -209,18 +239,24 @@ async function parseMetadata(event: NostrEvent, signal: AbortSignal): Promise<vo
await faviconCache.fetch(domain, { signal }); await faviconCache.fetch(domain, { signal });
} }
// Populate author_search. const search = [name, nip05].filter(Boolean).join(' ').trim();
try {
const search = result?.pubkey === event.pubkey ? [name, nip05].filter(Boolean).join(' ').trim() : name ?? '';
if (search) { if (search !== authorStats?.search) {
await kysely.insertInto('author_stats') updates.search = search;
.values({ pubkey: event.pubkey, search, followers_count: 0, following_count: 0, notes_count: 0 }) }
.onConflict((oc) => oc.column('pubkey').doUpdateSet({ search }))
.execute(); if (Object.keys(updates).length) {
} await kysely.insertInto('author_stats')
} catch { .values({
// do nothing pubkey: event.pubkey,
followers_count: 0,
following_count: 0,
notes_count: 0,
search,
...updates,
})
.onConflict((oc) => oc.column('pubkey').doUpdateSet(updates))
.execute();
} }
} }
@ -353,4 +389,4 @@ async function handleZaps(kysely: Kysely<DittoTables>, event: NostrEvent) {
} }
} }
export { handleEvent, handleZaps }; export { handleEvent, handleZaps, updateAuthorData };

View file

@ -1,29 +1,24 @@
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { NIP05, NStore } from '@nostrify/nostrify'; import { NIP05, NStore } from '@nostrify/nostrify';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import { Kysely } from 'kysely';
import tldts from 'tldts'; import tldts from 'tldts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { DittoTables } from '@/db/DittoTables.ts';
import { cachedNip05sSizeGauge } from '@/metrics.ts'; import { cachedNip05sSizeGauge } from '@/metrics.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { errorJson } from '@/utils/log.ts'; import { errorJson } from '@/utils/log.ts';
import { SimpleLRU } from '@/utils/SimpleLRU.ts'; import { SimpleLRU } from '@/utils/SimpleLRU.ts';
import { Nip05, nostrNow, parseNip05 } from '@/utils.ts';
import { fetchWorker } from '@/workers/fetch.ts'; import { fetchWorker } from '@/workers/fetch.ts';
export const nip05Cache = new SimpleLRU<string, nip19.ProfilePointer>( export const nip05Cache = new SimpleLRU<string, nip19.ProfilePointer>(
async (nip05, { signal }) => { async (nip05, { signal }) => {
const store = await Storages.db(); const store = await Storages.db();
const kysely = await Storages.kysely(); return getNip05(store, nip05, signal);
return getNip05(kysely, store, nip05, signal);
}, },
{ ...Conf.caches.nip05, gauge: cachedNip05sSizeGauge }, { ...Conf.caches.nip05, gauge: cachedNip05sSizeGauge },
); );
async function getNip05( async function getNip05(
kysely: Kysely<DittoTables>,
store: NStore, store: NStore,
nip05: string, nip05: string,
signal?: AbortSignal, signal?: AbortSignal,
@ -36,88 +31,26 @@ async function getNip05(
logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'started' }); logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'started' });
let pointer: nip19.ProfilePointer | undefined = await queryNip05(kysely, nip05);
if (pointer) {
logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'found', source: 'db', pubkey: pointer.pubkey });
return pointer;
}
const [name, domain] = nip05.split('@'); const [name, domain] = nip05.split('@');
try { try {
if (domain === Conf.url.host) { if (domain === Conf.url.host) {
pointer = await localNip05Lookup(store, name); const pointer = await localNip05Lookup(store, name);
if (pointer) { if (pointer) {
logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'found', source: 'local', pubkey: pointer.pubkey }); logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'found', source: 'local', pubkey: pointer.pubkey });
return pointer;
} else { } else {
throw new Error(`Not found: ${nip05}`); throw new Error(`Not found: ${nip05}`);
} }
} else { } else {
pointer = await NIP05.lookup(nip05, { fetch: fetchWorker, signal }); const pointer = await NIP05.lookup(nip05, { fetch: fetchWorker, signal });
logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'found', source: 'fetch', pubkey: pointer.pubkey }); logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'found', source: 'fetch', pubkey: pointer.pubkey });
return pointer;
} }
} catch (e) { } catch (e) {
logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'failed', error: errorJson(e) }); logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'failed', error: errorJson(e) });
throw e; throw e;
} }
insertNip05(kysely, nip05, pointer.pubkey).catch((e) => {
logi({ level: 'error', ns: 'ditto.nip05', nip05, state: 'insert_failed', error: errorJson(e) });
});
return pointer;
}
async function queryNip05(kysely: Kysely<DittoTables>, nip05: string): Promise<nip19.ProfilePointer | undefined> {
const row = await kysely
.selectFrom('author_stats')
.select('pubkey')
.where('nip05', '=', nip05)
.executeTakeFirst();
if (row) {
return { pubkey: row.pubkey };
}
}
async function insertNip05(kysely: Kysely<DittoTables>, nip05: string, pubkey: string, ts = nostrNow()): Promise<void> {
const tld = tldts.parse(nip05);
if (!tld.isIcann || tld.isIp || tld.isPrivate) {
throw new Error(`Invalid NIP-05: ${nip05}`);
}
await kysely
.insertInto('author_stats')
.values({
pubkey,
nip05,
nip05_domain: tld.domain,
nip05_hostname: tld.hostname,
nip05_last_verified_at: ts,
followers_count: 0, // TODO: fix `author_stats` types so setting these aren't required
following_count: 0,
notes_count: 0,
search: nip05,
})
.onConflict((oc) =>
oc
.column('pubkey')
.doUpdateSet({
nip05,
nip05_domain: tld.domain,
nip05_hostname: tld.hostname,
nip05_last_verified_at: ts,
})
.where((eb) =>
eb.or([
eb('author_stats.nip05_last_verified_at', '<', ts),
eb('author_stats.nip05_last_verified_at', 'is', null),
])
)
)
.execute();
} }
export async function localNip05Lookup(store: NStore, localpart: string): Promise<nip19.ProfilePointer | undefined> { export async function localNip05Lookup(store: NStore, localpart: string): Promise<nip19.ProfilePointer | undefined> {
@ -134,19 +67,3 @@ export async function localNip05Lookup(store: NStore, localpart: string): Promis
return { pubkey, relays: [Conf.relay] }; return { pubkey, relays: [Conf.relay] };
} }
} }
export async function parseAndVerifyNip05(
nip05: string | undefined,
pubkey: string,
signal = AbortSignal.timeout(3000),
): Promise<Nip05 | undefined> {
if (!nip05) return;
try {
const result = await nip05Cache.fetch(nip05, { signal });
if (result.pubkey === pubkey) {
return parseNip05(nip05);
}
} catch (_e) {
// do nothing
}
}