From ba241f0431f448a307b6f79fad6d22dd796faf19 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 20:47:53 -0500 Subject: [PATCH] Rework opengraph --- src/config.ts | 9 +- src/controllers/api/search.ts | 4 +- src/controllers/frontend.ts | 106 +++++++----------- src/entities/MastodonStatus.ts | 11 +- src/utils/lookup.ts | 2 +- src/utils/og-metadata.ts | 190 +++++++-------------------------- src/utils/outbox.ts | 2 +- src/views/meta.ts | 64 ++++++----- 8 files changed, 133 insertions(+), 255 deletions(-) diff --git a/src/config.ts b/src/config.ts index 9d89d026..d5b94da3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -251,9 +251,12 @@ class Conf { return optionalBooleanSchema.parse(Deno.env.get('CRON_ENABLED')) ?? true; } /** Crawler User-Agent regex to render link previews to. */ - static get crawlerRegex(): string { - return Deno.env.get('CRAWLER_REGEX') || - 'googlebot|bingbot|yandex|baiduspider|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterestbot|slackbot|vkShare|W3C_Validator|whatsapp|mastodon|pleroma|Discordbot|AhrefsBot|SEMrushBot|MJ12bot|SeekportBot|Synapse|Matrix'; + static get crawlerRegex(): RegExp { + return new RegExp( + Deno.env.get('CRAWLER_REGEX') || + 'googlebot|bingbot|yandex|baiduspider|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterestbot|slackbot|vkShare|W3C_Validator|whatsapp|mastodon|pleroma|Discordbot|AhrefsBot|SEMrushBot|MJ12bot|SeekportBot|Synapse|Matrix', + 'i', + ); } /** Path to the custom policy module. Must be an absolute path, https:, npm:, or jsr: URI. */ static get policy(): string { diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index 23eba60f..9bddc336 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -155,7 +155,7 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort } return filters; } catch { - // do nothing + // fall through } try { @@ -164,7 +164,7 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort return [{ kinds: [0], authors: [pubkey] }]; } } catch { - // do nothing + // fall through } return []; diff --git a/src/controllers/frontend.ts b/src/controllers/frontend.ts index 5c7e4c62..9f52a27e 100644 --- a/src/controllers/frontend.ts +++ b/src/controllers/frontend.ts @@ -2,81 +2,19 @@ import { AppMiddleware } from '@/app.ts'; import { Conf } from '@/config.ts'; import { Stickynotes } from '@soapbox/stickynotes'; import { Storages } from '@/storages.ts'; -import { - fetchProfile, - getHandle, - getPathParams, - getStatusInfo, - OpenGraphTemplateOpts, - PathParams, -} from '@/utils/og-metadata.ts'; +import { getPathParams, MetadataEntities } from '@/utils/og-metadata.ts'; import { getInstanceMetadata } from '@/utils/instance.ts'; -import { metadataView } from '@/views/meta.ts'; +import { lookupPubkey } from '@/utils/lookup.ts'; +import { renderMetadata } from '@/views/meta.ts'; +import { getAuthor, getEvent } from '@/queries.ts'; +import { renderStatus } from '@/views/mastodon/statuses.ts'; +import { renderAccount } from '@/views/mastodon/accounts.ts'; const console = new Stickynotes('ditto:frontend'); /** Placeholder to find & replace with metadata. */ const META_PLACEHOLDER = '' as const; -async function buildTemplateOpts(params: PathParams, url: string): Promise { - const store = await Storages.db(); - const meta = await getInstanceMetadata(store); - - const res: OpenGraphTemplateOpts = { - title: meta.name, - type: 'article', - description: meta.about, - url, - site: meta.name, - image: { - url: Conf.local('/favicon.ico'), - }, - }; - - try { - if (params.statusId) { - const { description, image, title } = await getStatusInfo(params.statusId); - - res.description = description; - res.title = title; - - if (res.image) { - res.image = image; - } - } else if (params.acct) { - const key = /^[a-f0-9]{64}$/.test(params.acct) ? 'pubkey' : 'handle'; - let handle = ''; - try { - const profile = await fetchProfile({ [key]: params.acct }); - handle = await getHandle(params.acct, profile); - - res.description = profile.meta.about; - - if (profile.meta.picture) { - res.image = { - url: profile.meta.picture, - }; - } - } catch { - console.debug(`couldn't find kind 0 for ${params.acct}`); - // @ts-ignore we don't want getHandle trying to do a lookup here - // but we do want it to give us a nice pretty npub - handle = await getHandle(params.acct, {}); - res.description = `@${handle}'s Nostr profile`; - } - - res.type = 'profile'; - res.title = `View @${handle}'s profile on Ditto`; - } - } catch (e) { - console.debug('Error getting OpenGraph metadata information:'); - console.debug(e); - console.trace(); - } - - return res; -} - export const frontendController: AppMiddleware = async (c, next) => { try { const content = await Deno.readTextFile(new URL('../../public/index.html', import.meta.url)); @@ -84,7 +22,7 @@ export const frontendController: AppMiddleware = async (c, next) => { const ua = c.req.header('User-Agent'); console.debug('ua', ua); - if (!new RegExp(Conf.crawlerRegex, 'i').test(ua ?? '')) { + if (!Conf.crawlerRegex.test(ua ?? '')) { return c.html(content); } @@ -92,7 +30,8 @@ export const frontendController: AppMiddleware = async (c, next) => { const params = getPathParams(c.req.path); if (params) { try { - const meta = metadataView(await buildTemplateOpts(params, Conf.local(c.req.path))); + const entities = await getEntities(params); + const meta = renderMetadata(c.req.url, entities); return c.html(content.replace(META_PLACEHOLDER, meta)); } catch (e) { console.log(`Error building meta tags: ${e}`); @@ -106,3 +45,30 @@ export const frontendController: AppMiddleware = async (c, next) => { await next(); } }; + +async function getEntities(params: { acct?: string; statusId?: string }): Promise { + const store = await Storages.db(); + + const entities: MetadataEntities = { + instance: await getInstanceMetadata(store), + }; + + if (params.statusId) { + const event = await getEvent(params.statusId, { kind: 1 }); + if (event) { + entities.status = await renderStatus(event, {}); + entities.account = entities.status?.account; + } + return entities; + } + + if (params.acct) { + const pubkey = await lookupPubkey(params.acct); + const event = pubkey ? await getAuthor(pubkey) : undefined; + if (event) { + entities.account = await renderAccount(event); + } + } + + return entities; +} diff --git a/src/entities/MastodonStatus.ts b/src/entities/MastodonStatus.ts index 20c52438..e446eb11 100644 --- a/src/entities/MastodonStatus.ts +++ b/src/entities/MastodonStatus.ts @@ -24,7 +24,16 @@ export interface MastodonStatus { pinned: boolean; reblog: MastodonStatus | null; application: unknown; - media_attachments: unknown[]; + media_attachments: { + type: string; + preview_url?: string; + meta?: { + original?: { + width?: number; + height?: number; + }; + }; + }[]; mentions: unknown[]; tags: unknown[]; emojis: unknown[]; diff --git a/src/utils/lookup.ts b/src/utils/lookup.ts index a824949a..8b082abd 100644 --- a/src/utils/lookup.ts +++ b/src/utils/lookup.ts @@ -75,7 +75,7 @@ export function extractIdentifier(value: string): string | undefined { } } } catch { - // do nothing + // fall through } value = value.replace(/^@/, ''); diff --git a/src/utils/og-metadata.ts b/src/utils/og-metadata.ts index beaaac95..c0e5756c 100644 --- a/src/utils/og-metadata.ts +++ b/src/utils/og-metadata.ts @@ -1,35 +1,20 @@ -import { NostrEvent, NostrMetadata, NSchema as n } from '@nostrify/nostrify'; -import { Stickynotes } from '@soapbox/stickynotes'; -import { nip19, nip27 } from 'nostr-tools'; +import { nip19 } from 'nostr-tools'; import { match } from 'path-to-regexp'; -import { getAuthor, getEvent } from '@/queries.ts'; -import { lookupPubkey } from '@/utils/lookup.ts'; -import { parseAndVerifyNip05 } from '@/utils/nip05.ts'; -import { parseNip05 } from '@/utils.ts'; +import { MastodonAccount } from '@/entities/MastodonAccount.ts'; +import { MastodonStatus } from '@/entities/MastodonStatus.ts'; +import { InstanceMetadata } from '@/utils/instance.ts'; -const console = new Stickynotes('ditto:frontend'); - -export interface OpenGraphTemplateOpts { - title: string; - type: 'article' | 'profile' | 'website'; - url: string; - image?: StatusInfo['image']; - description?: string; - site: string; +export interface MetadataEntities { + status?: MastodonStatus; + account?: MastodonAccount; + instance: InstanceMetadata; } -export type PathParams = Partial>; - -interface StatusInfo { - title: string; - description: string; - image?: { - url: string; - w?: number; - h?: number; - alt?: string; - }; +export interface MetadataPathParams { + statusId?: string; + acct?: string; + bech32?: string; } /** URL routes to serve metadata on. */ @@ -42,139 +27,40 @@ const SSR_ROUTES = [ '/statuses/:statusId', '/notice/:statusId', '/posts/:statusId', - '/note:note', - '/nevent:nevent', - '/nprofile:nprofile', - '/npub:npub', + '/:bech32', ] as const; -const SSR_ROUTE_MATCHERS = SSR_ROUTES.map((route) => match(route, { decode: decodeURIComponent })); +const SSR_ROUTE_MATCHERS = SSR_ROUTES.map((route) => match(route)); -export function getPathParams(path: string): PathParams | undefined { +export function getPathParams(path: string): MetadataPathParams | undefined { for (const matcher of SSR_ROUTE_MATCHERS) { const result = matcher(path); if (!result) continue; - const params = result.params as PathParams; - if (params.nevent) { - const decoded = nip19.decode(`nevent${params.nevent}`).data as nip19.EventPointer; - params.statusId = decoded.id; - } else if (params.note) { - params.statusId = nip19.decode(`note${params.note}`).data as string; + + const params: MetadataPathParams = result.params; + + if (params.bech32) { + try { + const decoded = nip19.decode(params.bech32); + switch (decoded.type) { + case 'nevent': + params.statusId = decoded.data.id; + break; + case 'note': + params.statusId = decoded.data; + break; + case 'nprofile': + params.acct = decoded.data.pubkey; + break; + case 'npub': + params.acct = decoded.data; + break; + } + } catch { + // fall through + } } - if (params.nprofile) { - const decoded = nip19.decode(`nprofile${params.nprofile}`).data as nip19.ProfilePointer; - params.acct = decoded.pubkey; - } else if (params.npub) { - params.acct = nip19.decode(`npub${params.npub}`).data as string; - } return params; } } - -export async function fetchProfile( - { pubkey, handle }: Partial>, -): Promise { - if (!handle && !pubkey) { - throw new Error('Tried to fetch kind 0 with no args'); - } - - if (handle) pubkey = await lookupPubkey(handle); - if (!pubkey) throw new Error('NIP-05 or bech32 specified and no pubkey found'); - - const author = await getAuthor(pubkey); - if (!author) throw new Error(`Author not found for pubkey ${pubkey}`); - - return { - meta: n.json() - .pipe(n.metadata()) - .parse(author.content), - event: author, - }; -} - -type ProfileInfo = { meta: NostrMetadata; event: NostrEvent }; - -function truncate(s: string, len: number, ellipsis = '…') { - if (s.length <= len) return s; - return s.slice(0, len) + ellipsis; -} - -/** - * @param id A nip-05 identifier, bech32 encoded npub/nprofile, or a pubkey - * @param acc A ProfileInfo object, if you've already fetched it then this is used to build a handle. - * @returns The handle - */ -export async function getHandle(id: string, acc?: ProfileInfo) { - let handle: string | undefined = ''; - - const pubkeyToHandle = async (pubkey: string) => { - const fallback = nip19.npubEncode(pubkey).slice(0, 8); - try { - const author = acc || await fetchProfile({ pubkey }); - if (author?.meta?.nip05) return parseNip05(author.meta.nip05).handle; - else if (author?.meta?.name) return author.meta.name; - } catch (e) { - console.debug('Error fetching profile/parsing nip-05 in getHandle'); - console.debug(e); - } - return fallback; - }; - - if (/[a-f0-9]{64}/.test(id)) { - handle = await pubkeyToHandle(id); - } else if (n.bech32().safeParse(id).success) { - if (id.startsWith('npub')) { - handle = await pubkeyToHandle(nip19.decode(id as `npub1${string}`).data); - } else if (id.startsWith('nprofile')) { - const decoded = nip19.decode(id as `nprofile1${string}`).data.pubkey; - handle = await pubkeyToHandle(decoded); - } else { - throw new Error('non-nprofile or -npub bech32 passed to getHandle()'); - } - } else { - const pubkey = await lookupPubkey(id); - if (!pubkey) throw new Error('Invalid user identifier'); - const parsed = await parseAndVerifyNip05(id, pubkey); - handle = parsed?.handle; - } - - return handle || name || 'npub1xxx'; -} - -export async function getStatusInfo(id: string): Promise { - const event = await getEvent(id); - if (!event) throw new Error('Invalid post id supplied'); - let title = 'View post on Ditto'; - try { - const handle = await getHandle(event.pubkey); - title = `View @${handle}'s post on Ditto`; - } catch (_) { - console.log(`Error getting status info for ${id}: proceeding silently`); - } - const res: StatusInfo = { - title, - description: nip27.replaceAll( - event.content, - ({ decoded, value }) => decoded.type === 'npub' ? value.slice(0, 8) : '', - ), - }; - - const data: string[][] = event.tags - .find(([name]) => name === 'imeta')?.slice(1) - .map((entry: string) => entry.split(' ')) ?? []; - - const url = data.find(([name]) => name === 'url')?.[1]; - const dim = data.find(([name]) => name === 'dim')?.[1]; - - const [w, h] = dim?.split('x').map(Number) ?? [null, null]; - - if (url && w && h) { - res.image = { url, w, h }; - res.description = res.description.replace(url.trim(), ''); - } - - // needs to be done last incase the image url was surrounded by newlines - res.description = truncate(res.description.trim(), 140); - return res; -} diff --git a/src/utils/outbox.ts b/src/utils/outbox.ts index 72b83388..891cccb8 100644 --- a/src/utils/outbox.ts +++ b/src/utils/outbox.ts @@ -18,7 +18,7 @@ export async function getRelays(store: NStore, pubkey: string): Promise`, - html` `, - html` `, - html` `, - html` `, - html` `, - ]; +export function renderMetadata(url: string, { account, status, instance }: MetadataEntities): string { + const tags: string[] = []; + + const title = account ? `${account.display_name} (@${account.acct})` : instance.name; + const attachment = status?.media_attachments?.find((a) => a.type === 'image'); + const description = status?.content || account?.note || instance.tagline; + const image = attachment?.preview_url || account?.avatar_static || instance.picture; + const siteName = instance?.name; + const width = attachment?.meta?.original?.width; + const height = attachment?.meta?.original?.height; + + if (title) { + tags.push(html`${title}`); + tags.push(html``); + tags.push(html``); + } if (description) { - res.push(html``); - res.push(html``); + tags.push(html``); + tags.push(html``); + tags.push(html``); } if (image) { - res.push(html``); - res.push(html``); - - if (image.w && image.h) { - res.push(html``); - res.push(html``); - } - - if (image.alt) { - res.push(html``); - res.push(html``); - } + tags.push(html``); + tags.push(html``); } - return res.join(''); + if (typeof width === 'number' && typeof height === 'number') { + tags.push(html``); + tags.push(html``); + } + + if (siteName) { + tags.push(html``); + } + + // Extra tags (always present if other tags exist). + if (tags.length > 0) { + tags.push(html``); + tags.push(''); + tags.push(''); + } + + return tags.join(''); }