diff --git a/src/db/DittoTables.ts b/src/db/DittoTables.ts index 7baaa42c..e07e7002 100644 --- a/src/db/DittoTables.ts +++ b/src/db/DittoTables.ts @@ -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; diff --git a/src/db/migrations/046_author_stats_nip05.ts b/src/db/migrations/046_author_stats_nip05.ts new file mode 100644 index 00000000..12c23773 --- /dev/null +++ b/src/db/migrations/046_author_stats_nip05.ts @@ -0,0 +1,48 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + 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): Promise { + await db.schema + .alterTable('author_stats') + .dropColumn('nip05') + .dropColumn('nip05_domain') + .dropColumn('nip05_hostname') + .dropColumn('nip05_last_verified_at') + .execute(); +} diff --git a/src/db/migrations/047_add_domain_favicons.ts b/src/db/migrations/047_add_domain_favicons.ts new file mode 100644 index 00000000..38bda03d --- /dev/null +++ b/src/db/migrations/047_add_domain_favicons.ts @@ -0,0 +1,15 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + 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): Promise { + await db.schema.dropTable('domain_favicons').execute(); +} diff --git a/src/interfaces/DittoEvent.ts b/src/interfaces/DittoEvent.ts index 293a7ab4..75db7f73 100644 --- a/src/interfaces/DittoEvent.ts +++ b/src/interfaces/DittoEvent.ts @@ -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. */ diff --git a/src/storages/hydrate.ts b/src/storages/hydrate.ts index 160dd1cc..b9f0fad0 100644 --- a/src/storages/hydrate.ts +++ b/src/storages/hydrate.ts @@ -66,9 +66,30 @@ async function hydrateEvents(opts: HydrateOpts): Promise { cache.push(event); } + const authorStats = await gatherAuthorStats(cache, kysely as Kysely); + const eventStats = await gatherEventStats(cache, kysely as Kysely); + + const domains = authorStats.reduce((result, { nip05_hostname }) => { + if (nip05_hostname) result.add(nip05_hostname); + return result; + }, new Set()); + + 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); + const stats = { - authors: await gatherAuthorStats(cache, kysely as Kysely), - events: await gatherEventStats(cache, kysely as Kysely), + authors: authorStats, + events: eventStats, + favicons, }; // Dedupe events. @@ -85,7 +106,11 @@ async function hydrateEvents(opts: HydrateOpts): Promise { 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; + }, ): 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); @@ -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, })); } diff --git a/src/utils/nip05.ts b/src/utils/nip05.ts index 65f425a3..3860a6cb 100644 --- a/src/utils/nip05.ts +++ b/src/utils/nip05.ts @@ -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( +export const nip05Cache = new SimpleLRU( async (nip05, { signal }) => { const tld = tldts.parse(nip05); @@ -46,7 +46,7 @@ const nip05Cache = new SimpleLRU( { ...Conf.caches.nip05, gauge: cachedNip05sSizeGauge }, ); -async function localNip05Lookup(store: NStore, localpart: string): Promise { +export async function localNip05Lookup(store: NStore, localpart: string): Promise { 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 }; diff --git a/src/utils/stats.ts b/src/utils/stats.ts index 0821fed2..0a675aee 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -323,6 +323,9 @@ export async function countAuthorStats( search, streak_start: null, streak_end: null, + nip05: null, + nip05_domain: null, + nip05_hostname: null, }; } diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts index 0c2d1dcc..3940b905 100644 --- a/src/views/mastodon/accounts.ts +++ b/src/views/mastodon/accounts.ts @@ -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, - opts: ToAccountOpts = {}, - signal = AbortSignal.timeout(3000), -): Promise { +function renderAccount(event: Omit, 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 { +function accountFromPubkey(pubkey: string, opts: ToAccountOpts = {}): MastodonAccount { const event: UnsignedEvent = { kind: 0, pubkey,