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(), '');