From 77d73c47eeb2a17d6538cfc4502d4ba4867ceb02 Mon Sep 17 00:00:00 2001 From: Siddharth Singh Date: Sun, 4 Aug 2024 11:07:29 +0530 Subject: [PATCH] move a bunch of utils to their own file --- src/middleware/serveStaticWithOG.ts | 180 ++++------------------------ src/utils/og-metadata.ts | 148 +++++++++++++++++++++++ 2 files changed, 170 insertions(+), 158 deletions(-) create mode 100644 src/utils/og-metadata.ts diff --git a/src/middleware/serveStaticWithOG.ts b/src/middleware/serveStaticWithOG.ts index 0630a6b0..3adf64f3 100644 --- a/src/middleware/serveStaticWithOG.ts +++ b/src/middleware/serveStaticWithOG.ts @@ -1,53 +1,35 @@ import { Context, Env, MiddlewareHandler, Next } from '@hono/hono'; import { serveStatic as baseServeStatic, ServeStaticOptions } from '@hono/hono/serve-static'; -import { match } from 'path-to-regexp'; import { html, r } from 'campfire.js'; -import { nip05Cache } from '@/utils/nip05.ts'; -import { getAuthor, getEvent } from '@/queries.ts'; -import { nip19 } from 'nostr-tools'; -import { NostrMetadata, NSchema as n } from '@nostrify/nostrify'; -import { getInstanceMetadata } from '@/utils/instance.ts'; -import { Storages } from '@/storages.ts'; import { Conf } from '@/config.ts'; +import { + getInstanceName, + getPathParams, + getProfileInfo, + getStatusInfo, + OpenGraphTemplateOpts, + PathParams, +} from '@/utils/og-metadata.ts'; + +/** Placeholder to find & replace with metadata. */ +const OG_META_PLACEHOLDER = '' as const; /* * TODO: implement caching for posts (LRUCache) */ -interface OpenGraphTemplateOpts { - title: string; - type: 'article' | 'profile' | 'website'; - url: string; - image?: StatusInfo['image']; - description: string; -} - -type PathParams = Partial>; - -interface StatusInfo { - description: string; - image?: { - url: string; - w: number; - h: number; - alt?: string; - }; -} - -const store = await Storages.db(); - -const instanceName = async () => { - const meta = await getInstanceMetadata(store, AbortSignal.timeout(1000)); - return meta?.name || 'Ditto'; -}; - +/** + * 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 = async ({ title, type, url, image, description }: OpenGraphTemplateOpts): Promise => html`\ - + ${ image @@ -81,129 +63,11 @@ const BLANK_META = (url: string) => description: 'Ditto, a decentralized, self-hosted social media server', }); -/** Placeholder to find & replace with metadata. */ -const OG_META_PLACEHOLDER = '' as const; - -/** URL routes to serve metadata on. */ -const SSR_ROUTES = [ - '/@:acct/posts/:statusId', - '/@:acct/:statusId', - '/@:acct', - '/users/:acct/statuses/:statusId', - '/users/:acct', - '/statuses/:statusId', - '/notice/:statusId', - '/posts/:statusId', - '/note:note', - '/nevent:nevent', - '/nprofile:nprofile', - '/npub:npub', -] as const; - -const SSR_ROUTE_MATCHERS = SSR_ROUTES.map((route) => match(route, { decode: decodeURIComponent })); - -const getPathParams = (path: string) => { - 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; - } - - 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; - } - console.log(params); - return params; - } -}; - -const normalizeHandle = async (handle: string) => { - const id = `${handle}`; - const parts = id.match(/(?:(.+))?@(.+)/); - if (parts) { - const key = `${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; - } - - // shouldn't ever happen for a well-formed link - return ''; -}; - -const getKind0 = async (handle: string | undefined): Promise => { - const id = await normalizeHandle(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 = '...') => { - if (s.length <= len) return s; - return s.slice(0, len) + ellipsis; -}; - -const getStatus = async (id: string | undefined, handle?: string): Promise => { - const event = await getEvent(id || ''); - if (!event || !id) { - return { description: `A post on Ditto by @${handle}` }; - } - - const res: StatusInfo = { - 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; - - 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; - } - } - } - - // @ts-ignore conditional assign - 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; -}; - const buildMetaTags = async (params: PathParams, url: string): Promise => { if (!params.acct && !params.statusId) return await BLANK_META(url); - const kind0 = await getKind0(params.acct); - const { description, image } = await getStatus(params.statusId || ''); + const kind0 = await getProfileInfo(params.acct); + const { description, image } = await getStatusInfo(params.statusId || ''); const handle = kind0.nip05 || kind0.name || 'npub1xxx'; if (params.acct && params.statusId) { @@ -242,9 +106,9 @@ const buildMetaTags = async (params: PathParams, url: string): Promise = return await BLANK_META(url); }; -export const serveStaticWithOG = ( +export function serveStaticWithOG( options: ServeStaticOptions, -): MiddlewareHandler => { +): MiddlewareHandler { // deno-lint-ignore require-await return async function serveStatic(c: Context, next: Next) { let file = ''; @@ -275,4 +139,4 @@ export const serveStaticWithOG = ( pathResolve, })(c, next); }; -}; +} diff --git a/src/utils/og-metadata.ts b/src/utils/og-metadata.ts new file mode 100644 index 00000000..94cee4ca --- /dev/null +++ b/src/utils/og-metadata.ts @@ -0,0 +1,148 @@ +import { Storages } from '@/storages.ts'; +import { getInstanceMetadata } from '@/utils/instance.ts'; +import { nip19 } from 'nostr-tools'; +import { match } from 'path-to-regexp'; +import { nip05Cache } from '@/utils/nip05.ts'; +import { NostrMetadata, NSchema as n } from '@nostrify/nostrify'; +import { getAuthor, getEvent } from '@/queries.ts'; + +export interface OpenGraphTemplateOpts { + title: string; + type: 'article' | 'profile' | 'website'; + url: string; + image?: StatusInfo['image']; + description: string; +} + +export type PathParams = Partial>; + +interface StatusInfo { + description: string; + image?: { + url: string; + w: number; + h: number; + alt?: string; + }; +} + +const store = await Storages.db(); +export const getInstanceName = async () => { + const meta = await getInstanceMetadata(store, AbortSignal.timeout(1000)); + return meta?.name || 'Ditto'; +}; + +/** URL routes to serve metadata on. */ +const SSR_ROUTES = [ + '/@:acct/posts/:statusId', + '/@:acct/:statusId', + '/@:acct', + '/users/:acct/statuses/:statusId', + '/users/:acct', + '/statuses/:statusId', + '/notice/:statusId', + '/posts/:statusId', + '/note:note', + '/nevent:nevent', + '/nprofile:nprofile', + '/npub:npub', +] as const; + +const SSR_ROUTE_MATCHERS = SSR_ROUTES.map((route) => match(route, { decode: decodeURIComponent })); + +export function getPathParams(path: string) { + 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; + } + + 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; + } + console.log(params); + return params; + } +} + +async function urlParamToPubkey(handle: string) { + const id = `${handle}`; + const parts = id.match(/(?:(.+))?@(.+)/); + if (parts) { + const key = `${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; + } + + // shouldn't ever happen for a well-formed link + return ''; +} + +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 = '...') => { + 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}` }; + } + + const res: StatusInfo = { + 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; + + 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; + } + } + } + + // @ts-ignore conditional assign + 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; +}