Add nip05 and favicon results to the database, make renderAccount synchronous

This commit is contained in:
Alex Gleason 2025-02-07 13:35:37 -06:00
parent c476596d0a
commit d9b0bc1437
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
8 changed files with 133 additions and 35 deletions

View file

@ -5,6 +5,7 @@ import { NPostgresSchema } from '@nostrify/db';
export interface DittoTables extends NPostgresSchema { export interface DittoTables extends NPostgresSchema {
auth_tokens: AuthTokenRow; auth_tokens: AuthTokenRow;
author_stats: AuthorStatsRow; author_stats: AuthorStatsRow;
domain_favicons: DomainFaviconRow;
event_stats: EventStatsRow; event_stats: EventStatsRow;
pubkey_domains: PubkeyDomainRow; pubkey_domains: PubkeyDomainRow;
event_zaps: EventZapRow; event_zaps: EventZapRow;
@ -19,6 +20,9 @@ interface AuthorStatsRow {
search: string; search: string;
streak_start: number | null; streak_start: number | null;
streak_end: number | null; streak_end: number | null;
nip05: string | null;
nip05_domain: string | null;
nip05_hostname: string | null;
} }
interface EventStatsRow { interface EventStatsRow {
@ -46,6 +50,12 @@ interface PubkeyDomainRow {
last_updated_at: number; last_updated_at: number;
} }
interface DomainFaviconRow {
domain: string;
favicon: string;
last_updated_at: number;
}
interface EventZapRow { interface EventZapRow {
receipt_id: string; receipt_id: string;
target_event_id: string; target_event_id: string;

View file

@ -0,0 +1,48 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.alterTable('author_stats')
.addColumn('nip05', 'varchar(320)')
.addColumn('nip05_domain', 'varchar(253)')
.addColumn('nip05_hostname', 'varchar(253)')
.addColumn('nip05_last_verified_at', 'integer')
.execute();
await db.schema
.alterTable('author_stats')
.addCheckConstraint('author_stats_nip05_domain_lowercase_chk', sql`nip05_domain = lower(nip05_domain)`)
.execute();
await db.schema
.alterTable('author_stats')
.addCheckConstraint('author_stats_nip05_hostname_lowercase_chk', sql`nip05_hostname = lower(nip05_hostname)`)
.execute();
await db.schema
.alterTable('author_stats')
.addCheckConstraint('author_stats_nip05_hostname_domain_chk', sql`nip05_hostname like '%' || nip05_domain`)
.execute();
await db.schema
.createIndex('author_stats_nip05_domain_idx')
.on('author_stats')
.column('nip05_domain')
.execute();
await db.schema
.createIndex('author_stats_nip05_hostname_idx')
.on('author_stats')
.column('nip05_hostname')
.execute();
}
export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema
.alterTable('author_stats')
.dropColumn('nip05')
.dropColumn('nip05_domain')
.dropColumn('nip05_hostname')
.dropColumn('nip05_last_verified_at')
.execute();
}

View file

@ -0,0 +1,15 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.createTable('domain_favicons')
.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:\\/\\/'`)
.execute();
}
export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable('domain_favicons').execute();
}

View file

@ -8,6 +8,11 @@ export interface AuthorStats {
notes_count: number; notes_count: number;
streak_start?: number; streak_start?: number;
streak_end?: number; streak_end?: number;
nip05?: string;
nip05_domain?: string;
nip05_hostname?: string;
nip05_last_verified_at?: number;
favicon?: string;
} }
/** Ditto internal stats for the event. */ /** Ditto internal stats for the event. */

View file

@ -66,9 +66,30 @@ async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
cache.push(event); cache.push(event);
} }
const authorStats = await gatherAuthorStats(cache, kysely as Kysely<DittoTables>);
const eventStats = await gatherEventStats(cache, kysely as Kysely<DittoTables>);
const domains = authorStats.reduce((result, { nip05_hostname }) => {
if (nip05_hostname) result.add(nip05_hostname);
return result;
}, new Set<string>());
const favicons = (
await kysely
.selectFrom('domain_favicons')
.select(['domain', 'favicon'])
.where('domain', 'in', [...domains])
.execute()
)
.reduce((result, { domain, favicon }) => {
result[domain] = favicon;
return result;
}, {} as Record<string, string>);
const stats = { const stats = {
authors: await gatherAuthorStats(cache, kysely as Kysely<DittoTables>), authors: authorStats,
events: await gatherEventStats(cache, kysely as Kysely<DittoTables>), events: eventStats,
favicons,
}; };
// Dedupe events. // Dedupe events.
@ -85,7 +106,11 @@ async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
export function assembleEvents( export function assembleEvents(
a: DittoEvent[], a: DittoEvent[],
b: DittoEvent[], b: DittoEvent[],
stats: { authors: DittoTables['author_stats'][]; events: DittoTables['event_stats'][] }, stats: {
authors: DittoTables['author_stats'][];
events: DittoTables['event_stats'][];
favicons: Record<string, string>;
},
): DittoEvent[] { ): DittoEvent[] {
const admin = Conf.pubkey; const admin = Conf.pubkey;
@ -94,6 +119,10 @@ export function assembleEvents(
...stat, ...stat,
streak_start: stat.streak_start ?? undefined, streak_start: stat.streak_start ?? undefined,
streak_end: stat.streak_end ?? undefined, streak_end: stat.streak_end ?? undefined,
nip05: stat.nip05 ?? undefined,
nip05_domain: stat.nip05_domain ?? undefined,
nip05_hostname: stat.nip05_hostname ?? undefined,
favicon: stats.favicons[stat.nip05_hostname!],
}; };
return result; return result;
}, {} as Record<string, DittoEvent['author_stats']>); }, {} as Record<string, DittoEvent['author_stats']>);
@ -390,13 +419,10 @@ async function gatherAuthorStats(
.execute(); .execute();
return rows.map((row) => ({ return rows.map((row) => ({
pubkey: row.pubkey, ...row,
followers_count: Math.max(0, row.followers_count), followers_count: Math.max(0, row.followers_count),
following_count: Math.max(0, row.following_count), following_count: Math.max(0, row.following_count),
notes_count: Math.max(0, row.notes_count), notes_count: Math.max(0, row.notes_count),
search: row.search,
streak_start: row.streak_start,
streak_end: row.streak_end,
})); }));
} }

View file

@ -11,7 +11,7 @@ import { SimpleLRU } from '@/utils/SimpleLRU.ts';
import { Nip05, parseNip05 } from '@/utils.ts'; import { Nip05, parseNip05 } from '@/utils.ts';
import { fetchWorker } from '@/workers/fetch.ts'; import { fetchWorker } from '@/workers/fetch.ts';
const nip05Cache = new SimpleLRU<string, nip19.ProfilePointer>( export const nip05Cache = new SimpleLRU<string, nip19.ProfilePointer>(
async (nip05, { signal }) => { async (nip05, { signal }) => {
const tld = tldts.parse(nip05); const tld = tldts.parse(nip05);
@ -46,7 +46,7 @@ const nip05Cache = new SimpleLRU<string, nip19.ProfilePointer>(
{ ...Conf.caches.nip05, gauge: cachedNip05sSizeGauge }, { ...Conf.caches.nip05, gauge: cachedNip05sSizeGauge },
); );
async function localNip05Lookup(store: NStore, localpart: string): Promise<nip19.ProfilePointer | undefined> { export async function localNip05Lookup(store: NStore, localpart: string): Promise<nip19.ProfilePointer | undefined> {
const [grant] = await store.query([{ const [grant] = await store.query([{
kinds: [30360], kinds: [30360],
'#d': [`${localpart}@${Conf.url.host}`], '#d': [`${localpart}@${Conf.url.host}`],
@ -76,5 +76,3 @@ export async function parseAndVerifyNip05(
// do nothing // do nothing
} }
} }
export { localNip05Lookup, nip05Cache };

View file

@ -323,6 +323,9 @@ export async function countAuthorStats(
search, search,
streak_start: null, streak_start: null,
streak_end: null, streak_end: null,
nip05: null,
nip05_domain: null,
nip05_hostname: null,
}; };
} }

View file

@ -6,11 +6,9 @@ import { MastodonAccount } from '@/entities/MastodonAccount.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { metadataSchema } from '@/schemas/nostr.ts'; import { metadataSchema } from '@/schemas/nostr.ts';
import { getLnurl } from '@/utils/lnurl.ts'; import { getLnurl } from '@/utils/lnurl.ts';
import { parseAndVerifyNip05 } from '@/utils/nip05.ts';
import { parseNoteContent } from '@/utils/note.ts'; import { parseNoteContent } from '@/utils/note.ts';
import { getTagSet } from '@/utils/tags.ts'; import { getTagSet } from '@/utils/tags.ts';
import { faviconCache } from '@/utils/favicon.ts'; import { nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
import { nostrDate, nostrNow } from '@/utils.ts';
import { renderEmojis } from '@/views/mastodon/emojis.ts'; import { renderEmojis } from '@/views/mastodon/emojis.ts';
type ToAccountOpts = { type ToAccountOpts = {
@ -20,16 +18,14 @@ type ToAccountOpts = {
withSource?: false; withSource?: false;
}; };
async function renderAccount( function renderAccount(event: Omit<DittoEvent, 'id' | 'sig'>, opts: ToAccountOpts = {}): MastodonAccount {
event: Omit<DittoEvent, 'id' | 'sig'>,
opts: ToAccountOpts = {},
signal = AbortSignal.timeout(3000),
): Promise<MastodonAccount> {
const { pubkey } = event; const { pubkey } = event;
const stats = event.author_stats;
const names = getTagSet(event.user?.tags ?? [], 'n'); const names = getTagSet(event.user?.tags ?? [], 'n');
if (names.has('disabled')) { if (names.has('disabled')) {
const account = await accountFromPubkey(pubkey, opts); const account = accountFromPubkey(pubkey, opts);
account.pleroma.deactivated = true; account.pleroma.deactivated = true;
return account; return account;
} }
@ -48,17 +44,14 @@ async function renderAccount(
const npub = nip19.npubEncode(pubkey); const npub = nip19.npubEncode(pubkey);
const nprofile = nip19.nprofileEncode({ pubkey, relays: [Conf.relay] }); const nprofile = nip19.nprofileEncode({ pubkey, relays: [Conf.relay] });
const parsed05 = await parseAndVerifyNip05(nip05, pubkey, signal); const parsed05 = stats?.nip05 ? parseNip05(stats.nip05) : undefined;
const acct = parsed05?.handle || npub; const acct = parsed05?.handle || npub;
let favicon: URL | undefined; let favicon: string | undefined = stats?.favicon;
if (parsed05?.domain) { if (!favicon && parsed05) {
try { favicon = new URL('/favicon.ico', `https://${parsed05.domain}/`).toString();
favicon = await faviconCache.fetch(parsed05.domain, { signal });
} catch {
favicon = new URL('/favicon.ico', `https://${parsed05.domain}/`);
}
} }
const { html } = parseNoteContent(about || '', []); const { html } = parseNoteContent(about || '', []);
const fields = _fields const fields = _fields
@ -70,8 +63,8 @@ async function renderAccount(
})) ?? []; })) ?? [];
let streakDays = 0; let streakDays = 0;
let streakStart = event.author_stats?.streak_start ?? null; let streakStart = stats?.streak_start ?? null;
let streakEnd = event.author_stats?.streak_end ?? null; let streakEnd = stats?.streak_end ?? null;
const { streakWindow } = Conf; const { streakWindow } = Conf;
if (streakStart && streakEnd) { if (streakStart && streakEnd) {
@ -97,8 +90,8 @@ async function renderAccount(
emojis: renderEmojis(event), emojis: renderEmojis(event),
fields: fields.map((field) => ({ ...field, value: parseNoteContent(field.value, []).html })), fields: fields.map((field) => ({ ...field, value: parseNoteContent(field.value, []).html })),
follow_requests_count: 0, follow_requests_count: 0,
followers_count: event.author_stats?.followers_count ?? 0, followers_count: stats?.followers_count ?? 0,
following_count: event.author_stats?.following_count ?? 0, following_count: stats?.following_count ?? 0,
fqn: parsed05?.handle || npub, fqn: parsed05?.handle || npub,
header: banner, header: banner,
header_static: banner, header_static: banner,
@ -122,7 +115,7 @@ async function renderAccount(
}, },
} }
: undefined, : undefined,
statuses_count: event.author_stats?.notes_count ?? 0, statuses_count: stats?.notes_count ?? 0,
uri: Conf.local(`/users/${acct}`), uri: Conf.local(`/users/${acct}`),
url: Conf.local(`/@${acct}`), url: Conf.local(`/@${acct}`),
username: parsed05?.nickname || npub.substring(0, 8), username: parsed05?.nickname || npub.substring(0, 8),
@ -144,7 +137,7 @@ async function renderAccount(
is_local: parsed05?.domain === Conf.url.host, is_local: parsed05?.domain === Conf.url.host,
settings_store: opts.withSource ? opts.settingsStore : undefined, settings_store: opts.withSource ? opts.settingsStore : undefined,
tags: [...getTagSet(event.user?.tags ?? [], 't')], tags: [...getTagSet(event.user?.tags ?? [], 't')],
favicon: favicon?.toString(), favicon,
}, },
nostr: { nostr: {
pubkey, pubkey,
@ -154,7 +147,7 @@ async function renderAccount(
}; };
} }
function accountFromPubkey(pubkey: string, opts: ToAccountOpts = {}): Promise<MastodonAccount> { function accountFromPubkey(pubkey: string, opts: ToAccountOpts = {}): MastodonAccount {
const event: UnsignedEvent = { const event: UnsignedEvent = {
kind: 0, kind: 0,
pubkey, pubkey,