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 {
auth_tokens: AuthTokenRow;
author_stats: AuthorStatsRow;
domain_favicons: DomainFaviconRow;
event_stats: EventStatsRow;
pubkey_domains: PubkeyDomainRow;
event_zaps: EventZapRow;
@ -19,6 +20,9 @@ interface AuthorStatsRow {
search: string;
streak_start: number | null;
streak_end: number | null;
nip05: string | null;
nip05_domain: string | null;
nip05_hostname: string | null;
}
interface EventStatsRow {
@ -46,6 +50,12 @@ interface PubkeyDomainRow {
last_updated_at: number;
}
interface DomainFaviconRow {
domain: string;
favicon: string;
last_updated_at: number;
}
interface EventZapRow {
receipt_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;
streak_start?: 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. */

View file

@ -66,9 +66,30 @@ async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
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 = {
authors: await gatherAuthorStats(cache, kysely as Kysely<DittoTables>),
events: await gatherEventStats(cache, kysely as Kysely<DittoTables>),
authors: authorStats,
events: eventStats,
favicons,
};
// Dedupe events.
@ -85,7 +106,11 @@ async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
export function assembleEvents(
a: 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[] {
const admin = Conf.pubkey;
@ -94,6 +119,10 @@ export function assembleEvents(
...stat,
streak_start: stat.streak_start ?? 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;
}, {} as Record<string, DittoEvent['author_stats']>);
@ -390,13 +419,10 @@ async function gatherAuthorStats(
.execute();
return rows.map((row) => ({
pubkey: row.pubkey,
...row,
followers_count: Math.max(0, row.followers_count),
following_count: Math.max(0, row.following_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 { fetchWorker } from '@/workers/fetch.ts';
const nip05Cache = new SimpleLRU<string, nip19.ProfilePointer>(
export const nip05Cache = new SimpleLRU<string, nip19.ProfilePointer>(
async (nip05, { signal }) => {
const tld = tldts.parse(nip05);
@ -46,7 +46,7 @@ const nip05Cache = new SimpleLRU<string, nip19.ProfilePointer>(
{ ...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([{
kinds: [30360],
'#d': [`${localpart}@${Conf.url.host}`],
@ -76,5 +76,3 @@ export async function parseAndVerifyNip05(
// do nothing
}
}
export { localNip05Lookup, nip05Cache };

View file

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