Hook everything up? (In a messy way)

This commit is contained in:
Alex Gleason 2025-02-07 15:39:25 -06:00
parent d9b0bc1437
commit 93141c1db1
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
7 changed files with 184 additions and 87 deletions

View file

@ -23,6 +23,7 @@ interface AuthorStatsRow {
nip05: string | null;
nip05_domain: string | null;
nip05_hostname: string | null;
nip05_last_verified_at: number | null;
}
interface EventStatsRow {

View file

@ -6,7 +6,7 @@ export async function up(db: Kysely<unknown>): Promise<void> {
.addColumn('domain', 'varchar(253)', (col) => col.primaryKey())
.addColumn('favicon', 'varchar(2048)', (col) => col.notNull())
.addColumn('last_updated_at', 'integer', (col) => col.notNull())
.addCheckConstraint('domain_favicons_https_chk', sql`url ~* '^https:\\/\\/'`)
.addCheckConstraint('domain_favicons_https_chk', sql`favicon ~* '^https:\\/\\/'`)
.execute();
}

View file

@ -1,6 +1,6 @@
import { NKinds, NostrEvent, NSchema as n } from '@nostrify/nostrify';
import { logi } from '@soapbox/logi';
import { Kysely, sql } from 'kysely';
import { Kysely } from 'kysely';
import { z } from 'zod';
import { pipelineEncounters } from '@/caches/pipelineEncounters.ts';
@ -13,8 +13,9 @@ import { RelayError } from '@/RelayError.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { Storages } from '@/storages.ts';
import { eventAge, parseNip05, Time } from '@/utils.ts';
import { eventAge, Time } from '@/utils.ts';
import { getAmount } from '@/utils/bolt11.ts';
import { faviconCache } from '@/utils/favicon.ts';
import { errorJson } from '@/utils/log.ts';
import { nip05Cache } from '@/utils/nip05.ts';
import { purifyEvent } from '@/utils/purify.ts';
@ -202,6 +203,12 @@ async function parseMetadata(event: NostrEvent, signal: AbortSignal): Promise<vo
const { name, nip05 } = metadata.data;
const result = nip05 ? await nip05Cache.fetch(nip05, { signal }).catch(() => undefined) : undefined;
// Fetch favicon.
const domain = nip05?.split('@')[1].toLowerCase();
if (domain) {
await faviconCache.fetch(domain, { signal });
}
// Populate author_search.
try {
const search = result?.pubkey === event.pubkey ? [name, nip05].filter(Boolean).join(' ').trim() : name ?? '';
@ -215,24 +222,6 @@ async function parseMetadata(event: NostrEvent, signal: AbortSignal): Promise<vo
} catch {
// do nothing
}
if (nip05 && result && result.pubkey === event.pubkey) {
// Track pubkey domain.
try {
const { domain } = parseNip05(nip05);
await sql`
INSERT INTO pubkey_domains (pubkey, domain, last_updated_at)
VALUES (${event.pubkey}, ${domain}, ${event.created_at})
ON CONFLICT(pubkey) DO UPDATE SET
domain = excluded.domain,
last_updated_at = excluded.last_updated_at
WHERE excluded.last_updated_at > pubkey_domains.last_updated_at
`.execute(kysely);
} catch (_e) {
// do nothing
}
}
}
/** Determine if the event is being received in a timely manner. */

View file

@ -122,6 +122,7 @@ export function assembleEvents(
nip05: stat.nip05 ?? undefined,
nip05_domain: stat.nip05_domain ?? undefined,
nip05_hostname: stat.nip05_hostname ?? undefined,
nip05_last_verified_at: stat.nip05_last_verified_at ?? undefined,
favicon: stats.favicons[stat.nip05_hostname!],
};
return result;

View file

@ -1,14 +1,54 @@
import { DOMParser } from '@b-fuze/deno-dom';
import { logi } from '@soapbox/logi';
import { Kysely } from 'kysely';
import tldts from 'tldts';
import { Conf } from '@/config.ts';
import { DittoTables } from '@/db/DittoTables.ts';
import { cachedFaviconsSizeGauge } from '@/metrics.ts';
import { Storages } from '@/storages.ts';
import { nostrNow } from '@/utils.ts';
import { SimpleLRU } from '@/utils/SimpleLRU.ts';
import { fetchWorker } from '@/workers/fetch.ts';
const faviconCache = new SimpleLRU<string, URL>(
export const faviconCache = new SimpleLRU<string, URL>(
async (domain, { signal }) => {
const kysely = await Storages.kysely();
const row = await queryFavicon(kysely, domain);
if (row && (nostrNow() - row.last_updated_at) < (Conf.caches.favicon.ttl / 1000)) {
return new URL(row.favicon);
}
const url = await fetchFavicon(domain, signal);
insertFavicon(kysely, domain, url.href).catch(() => {});
return url;
},
{ ...Conf.caches.favicon, gauge: cachedFaviconsSizeGauge },
);
async function queryFavicon(
kysely: Kysely<DittoTables>,
domain: string,
): Promise<DittoTables['domain_favicons'] | undefined> {
return await kysely
.selectFrom('domain_favicons')
.selectAll()
.where('domain', '=', domain)
.executeTakeFirst();
}
async function insertFavicon(kysely: Kysely<DittoTables>, domain: string, favicon: string): Promise<void> {
await kysely
.insertInto('domain_favicons')
.values({ domain, favicon, last_updated_at: nostrNow() })
.execute();
}
async function fetchFavicon(domain: string, signal?: AbortSignal): Promise<URL> {
logi({ level: 'info', ns: 'ditto.favicon', domain, state: 'started' });
const tld = tldts.parse(domain);
@ -48,8 +88,4 @@ const faviconCache = new SimpleLRU<string, URL>(
logi({ level: 'info', ns: 'ditto.favicon', domain, state: 'failed' });
throw new Error(`Favicon not found: ${domain}`);
},
{ ...Conf.caches.favicon, gauge: cachedFaviconsSizeGauge },
);
export { faviconCache };
}

View file

@ -1,50 +1,119 @@
import { nip19 } from 'nostr-tools';
import { NIP05, NStore } from '@nostrify/nostrify';
import { logi } from '@soapbox/logi';
import { Kysely } from 'kysely';
import tldts from 'tldts';
import { Conf } from '@/config.ts';
import { DittoTables } from '@/db/DittoTables.ts';
import { cachedNip05sSizeGauge } from '@/metrics.ts';
import { Storages } from '@/storages.ts';
import { errorJson } from '@/utils/log.ts';
import { SimpleLRU } from '@/utils/SimpleLRU.ts';
import { Nip05, parseNip05 } from '@/utils.ts';
import { Nip05, nostrNow, parseNip05 } from '@/utils.ts';
import { fetchWorker } from '@/workers/fetch.ts';
export const nip05Cache = new SimpleLRU<string, nip19.ProfilePointer>(
async (nip05, { signal }) => {
const store = await Storages.db();
const kysely = await Storages.kysely();
return getNip05(kysely, store, nip05, signal);
},
{ ...Conf.caches.nip05, gauge: cachedNip05sSizeGauge },
);
async function getNip05(
kysely: Kysely<DittoTables>,
store: NStore,
nip05: string,
signal?: AbortSignal,
): Promise<nip19.ProfilePointer> {
const tld = tldts.parse(nip05);
if (!tld.isIcann || tld.isIp || tld.isPrivate) {
throw new Error(`Invalid NIP-05: ${nip05}`);
}
const [name, domain] = nip05.split('@');
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('@');
try {
if (domain === Conf.url.host) {
const store = await Storages.db();
const pointer = await localNip05Lookup(store, name);
pointer = await localNip05Lookup(store, name);
if (pointer) {
logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'found', pubkey: pointer.pubkey });
return pointer;
logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'found', source: 'local', pubkey: pointer.pubkey });
} else {
throw new Error(`Not found: ${nip05}`);
}
} else {
const result = await NIP05.lookup(nip05, { fetch: fetchWorker, signal });
logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'found', pubkey: result.pubkey });
return result;
pointer = await NIP05.lookup(nip05, { fetch: fetchWorker, signal });
logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'found', source: 'fetch', pubkey: pointer.pubkey });
}
} catch (e) {
logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'failed', error: errorJson(e) });
throw e;
}
},
{ ...Conf.caches.nip05, gauge: cachedNip05sSizeGauge },
);
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('nip05_last_verified_at', '<', ts)
)
.execute();
}
export async function localNip05Lookup(store: NStore, localpart: string): Promise<nip19.ProfilePointer | undefined> {
const [grant] = await store.query([{

View file

@ -326,6 +326,7 @@ export async function countAuthorStats(
nip05: null,
nip05_domain: null,
nip05_hostname: null,
nip05_last_verified_at: null,
};
}