From 33da9a41b27d7b89b05ca4c197baff830f836d8f Mon Sep 17 00:00:00 2001 From: Siddharth Singh Date: Mon, 5 Aug 2024 20:04:26 +0530 Subject: [PATCH] rewrite metadata generation --- src/controllers/frontend.ts | 101 +++++++++--------------------------- src/utils/og-metadata.ts | 93 +++++++++++++++------------------ 2 files changed, 65 insertions(+), 129 deletions(-) diff --git a/src/controllers/frontend.ts b/src/controllers/frontend.ts index 773ffb7d..4b01329f 100644 --- a/src/controllers/frontend.ts +++ b/src/controllers/frontend.ts @@ -1,8 +1,8 @@ import { AppMiddleware } from '@/app.ts'; import { Conf } from '@/config.ts'; -import { html } from '@/utils/html.ts'; import { Storages } from '@/storages.ts'; import { + getHandle, getPathParams, getProfileInfo, getStatusInfo, @@ -10,6 +10,7 @@ import { PathParams, } from '@/utils/og-metadata.ts'; import { getInstanceMetadata } from '@/utils/instance.ts'; +import { metadataView } from '@/views/meta.ts'; /** Placeholder to find & replace with metadata. */ const META_PLACEHOLDER = '' as const; @@ -18,88 +19,34 @@ const META_PLACEHOLDER = '' as const; * TODO: implement caching for posts (LRUCache) */ -/** - * Builds a series of meta tags from supplied metadata for injection into the served HTML page. - * @param opts the metadata to use to fill the template. - * @returns the built OpenGraph metadata. - */ -const tpl = ({ title, type, url, image, description, site }: OpenGraphTemplateOpts): string => { - const res = []; - res.push(html`\ - - - - - - - - - `); - - if (image) { - res.push(html`\ - - - - - `); - if (image.alt) { - res.push(html``); - res.push(html``); - } - } - - return res.join('\n').replace(/\n+/g, '\n').replace(/^[ ]+/gm, ''); -}; - const store = await Storages.db(); -async function buildMetaTags(params: PathParams, url: string): Promise { - // should never happen - if (!params.acct && !params.statusId) return ''; - +async function buildTemplateOpts(params: PathParams, url: string): Promise { const meta = await getInstanceMetadata(store); - const kind0 = await getProfileInfo(params.acct); - const { description, image } = await getStatusInfo(params.statusId || ''); - const handle = kind0.nip05?.replace(/^_@/, '') || kind0.name || 'npub1xxx'; + const res: OpenGraphTemplateOpts = { + title: `View this page on ${meta.name}`, + type: 'article', + description: meta.about, + url, + site: meta.name, + }; - if (params.acct && params.statusId) { - return tpl({ - title: `View @${handle}'s post on Ditto`, - type: 'article', - image, - description, - url, - site: meta.name, - }); - } else if (params.acct) { - return tpl({ - title: `View @${handle}'s profile on Ditto`, - type: 'profile', - description: kind0.about || '', - url, - site: meta.name, - image: kind0.picture - ? { - url: kind0.picture, - // Time will tell if this is fine. - h: 150, - w: 150, - } - : undefined, - }); + if (params.acct && !params.statusId) { + const profile = await getProfileInfo(params.acct); + res.type = 'profile'; + res.title = `View @${await getHandle(params.acct)}'s profile on Ditto`; + res.description = profile.about; + if (profile.picture) { + res.image = { url: profile.picture, h: 150, w: 150 }; + } } else if (params.statusId) { - return tpl({ - title: `View post on Ditto`, - type: 'profile', - description, - image, - url, - site: meta.name, - }); + const { description, image, title } = await getStatusInfo(params.statusId); + res.description = description; + res.image = image; + res.title = title; } - return ''; + return res; } export const frontendController: AppMiddleware = async (c, next) => { @@ -109,7 +56,7 @@ export const frontendController: AppMiddleware = async (c, next) => { const params = getPathParams(c.req.path); if (params) { - const meta = await buildMetaTags(params, Conf.local(c.req.path)); + const meta = metadataView(await buildTemplateOpts(params, Conf.local(c.req.path))); return c.html(content.replace(META_PLACEHOLDER, meta)); } } diff --git a/src/utils/og-metadata.ts b/src/utils/og-metadata.ts index ba3ebb8f..33a2e201 100644 --- a/src/utils/og-metadata.ts +++ b/src/utils/og-metadata.ts @@ -1,8 +1,9 @@ import { NostrMetadata, NSchema as n } from '@nostrify/nostrify'; -import { getAuthor, getEvent } from '@/queries.ts'; -import { nip05Cache } from '@/utils/nip05.ts'; +import { getEvent } from '@/queries.ts'; import { match } from 'path-to-regexp'; import { nip19 } from 'nostr-tools'; +import { lookupAccount, lookupPubkey } from '@/utils/lookup.ts'; +import { parseAndVerifyNip05 } from '@/utils/nip05.ts'; export interface OpenGraphTemplateOpts { title: string; @@ -16,6 +17,7 @@ export interface OpenGraphTemplateOpts { export type PathParams = Partial>; interface StatusInfo { + title: string; description: string; image?: { url: string; @@ -65,70 +67,57 @@ export function getPathParams(path: string) { } } -async function urlParamToPubkey(handle: string) { - const id = `${handle}`; - const parts = id.match(/(?:(.+)@)?(.+)/); - if (parts) { - const key = `${parts[1] ? (parts[1] + '@') : ''}${parts[2]}`; - return await nip05Cache.fetch(key, { signal: AbortSignal.timeout(1000) }).then((res) => res.pubkey); - } else if (id.startsWith('npub1')) { - return nip19.decode(id as `npub1${string}`).data; - } else if (/(?:[0-9]|[a-f]){64}/.test(id)) { - return id; - } +type ProfileInfo = { name: string; about: string } & NostrMetadata; - // shouldn't ever happen for a well-formed link - return ''; +/** + * Look up the name and bio of a user for use in generating OpenGraph metadata. + * + * @param handle The bech32 / nip05 identifier for the user, obtained from the URL. + * @returns An object containing the `name` and `about` fields of the user's kind 0, + * or sensible defaults if the kind 0 has those values missing. + */ +export async function getProfileInfo(handle: string | undefined): Promise { + const acc = await lookupAccount(handle || ''); + if (!acc) throw new Error('Invalid handle specified, or account not found.'); + + const short = nip19.npubEncode(acc.id).slice(0, 8); + const { name = short, about = `@${short}'s Nostr profile` } = n.json().pipe(n.metadata()).parse(acc.content); + + return { name, about }; } -export async function getProfileInfo(handle: string | undefined): Promise { - const id = await urlParamToPubkey(handle || ''); - const kind0 = await getAuthor(id); - - const short = nip19.npubEncode(id).substring(0, 8); - const blank = { name: short, about: `@${short}'s ditto profile` }; - if (!kind0) return blank; - - return Object.assign( - blank, - n.json().pipe(n.metadata()).parse(kind0.content), - ); -} - -const truncate = (s: string, len: number, ellipsis = '...') => { +function truncate(s: string, len: number, ellipsis = '…') { if (s.length <= len) return s; return s.slice(0, len) + ellipsis; -}; +} -export async function getStatusInfo(id: string | undefined, handle?: string): Promise { - const event = await getEvent(id || ''); - if (!event || !id) { - return { description: `A post on Ditto by @${handle}` }; - } +export async function getHandle(id: string, name?: string | undefined) { + const pubkey = /[a-z][0-9]{64}/.test(id) ? id : await lookupPubkey(id); + if (!pubkey) throw new Error('Invalid user identifier'); + const parsed = await parseAndVerifyNip05(id, pubkey); + return parsed?.handle || name || 'npub1xxx'; +} +export async function getStatusInfo(id: string): Promise { + const event = await getEvent(id); + if (!id || !event) throw new Error('Invalid post id supplied'); + + const handle = await getHandle(event.pubkey); const res: StatusInfo = { + title: `View @${handle}'s post on Ditto`, description: event.content .replace(/nostr:(npub1(?:[0-9]|[a-z]){58})/g, (_, key: string) => `@${key.slice(0, 8)}`), }; - let url: string; - let w: number; - let h: number; + const data: string[][] = event.tags + .find(([name]) => name === 'imeta')?.slice(1) + .map((entry: string) => entry.split(' ')) ?? []; - for (const [tag, ...values] of event.tags) { - if (tag !== 'imeta') continue; - for (const value of values) { - const [item, datum] = value.split(' '); - if (!['dim', 'url'].includes(item)) continue; - if (item === 'dim') { - [w, h] = datum.split('x').map(Number); - } else if (item === 'url') { - url = datum; - } - } - } + 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]; - // @ts-ignore conditional assign if (url && w && h) { res.image = { url, w, h }; res.description = res.description.replace(url.trim(), '');