mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
rewrite metadata generation
This commit is contained in:
parent
5785f07052
commit
33da9a41b2
2 changed files with 65 additions and 129 deletions
|
|
@ -1,8 +1,8 @@
|
||||||
import { AppMiddleware } from '@/app.ts';
|
import { AppMiddleware } from '@/app.ts';
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import { html } from '@/utils/html.ts';
|
|
||||||
import { Storages } from '@/storages.ts';
|
import { Storages } from '@/storages.ts';
|
||||||
import {
|
import {
|
||||||
|
getHandle,
|
||||||
getPathParams,
|
getPathParams,
|
||||||
getProfileInfo,
|
getProfileInfo,
|
||||||
getStatusInfo,
|
getStatusInfo,
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
PathParams,
|
PathParams,
|
||||||
} from '@/utils/og-metadata.ts';
|
} from '@/utils/og-metadata.ts';
|
||||||
import { getInstanceMetadata } from '@/utils/instance.ts';
|
import { getInstanceMetadata } from '@/utils/instance.ts';
|
||||||
|
import { metadataView } from '@/views/meta.ts';
|
||||||
|
|
||||||
/** Placeholder to find & replace with metadata. */
|
/** Placeholder to find & replace with metadata. */
|
||||||
const META_PLACEHOLDER = '<!--server-generated-meta-->' as const;
|
const META_PLACEHOLDER = '<!--server-generated-meta-->' as const;
|
||||||
|
|
@ -18,88 +19,34 @@ const META_PLACEHOLDER = '<!--server-generated-meta-->' as const;
|
||||||
* TODO: implement caching for posts (LRUCache)
|
* 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`\
|
|
||||||
<meta content="${title}" property="og:title">
|
|
||||||
<meta content="${type}" property="og:type">
|
|
||||||
<meta content="${url}" property="og:url">
|
|
||||||
<meta content="${description}" property="og:description">
|
|
||||||
<meta content="${site}" property="og:site_name">
|
|
||||||
<meta name="twitter:card" content="summary">
|
|
||||||
<meta name="twitter:title" content="${title}">
|
|
||||||
<meta name="twitter:description" content="${description}">
|
|
||||||
`);
|
|
||||||
|
|
||||||
if (image) {
|
|
||||||
res.push(html`\
|
|
||||||
<meta content="${image.url}" property="og:image">
|
|
||||||
<meta content="${image.w}" property="og:image:width">
|
|
||||||
<meta content="${image.h}" property="og:image:height">
|
|
||||||
<meta name="twitter:image" content="${image.url}">
|
|
||||||
`);
|
|
||||||
if (image.alt) {
|
|
||||||
res.push(html`<meta content="${image.alt}" property="og:image:alt">`);
|
|
||||||
res.push(html`<meta content="${image.alt}" property="twitter:image:alt">`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.join('\n').replace(/\n+/g, '\n').replace(/^[ ]+/gm, '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const store = await Storages.db();
|
const store = await Storages.db();
|
||||||
|
|
||||||
async function buildMetaTags(params: PathParams, url: string): Promise<string> {
|
async function buildTemplateOpts(params: PathParams, url: string): Promise<OpenGraphTemplateOpts> {
|
||||||
// should never happen
|
|
||||||
if (!params.acct && !params.statusId) return '';
|
|
||||||
|
|
||||||
const meta = await getInstanceMetadata(store);
|
const meta = await getInstanceMetadata(store);
|
||||||
const kind0 = await getProfileInfo(params.acct);
|
const res: OpenGraphTemplateOpts = {
|
||||||
const { description, image } = await getStatusInfo(params.statusId || '');
|
title: `View this page on ${meta.name}`,
|
||||||
const handle = kind0.nip05?.replace(/^_@/, '') || kind0.name || 'npub1xxx';
|
|
||||||
|
|
||||||
if (params.acct && params.statusId) {
|
|
||||||
return tpl({
|
|
||||||
title: `View @${handle}'s post on Ditto`,
|
|
||||||
type: 'article',
|
type: 'article',
|
||||||
image,
|
description: meta.about,
|
||||||
description,
|
|
||||||
url,
|
url,
|
||||||
site: meta.name,
|
site: meta.name,
|
||||||
});
|
};
|
||||||
} else if (params.acct) {
|
|
||||||
return tpl({
|
if (params.acct && !params.statusId) {
|
||||||
title: `View @${handle}'s profile on Ditto`,
|
const profile = await getProfileInfo(params.acct);
|
||||||
type: 'profile',
|
res.type = 'profile';
|
||||||
description: kind0.about || '',
|
res.title = `View @${await getHandle(params.acct)}'s profile on Ditto`;
|
||||||
url,
|
res.description = profile.about;
|
||||||
site: meta.name,
|
if (profile.picture) {
|
||||||
image: kind0.picture
|
res.image = { url: profile.picture, h: 150, w: 150 };
|
||||||
? {
|
|
||||||
url: kind0.picture,
|
|
||||||
// Time will tell if this is fine.
|
|
||||||
h: 150,
|
|
||||||
w: 150,
|
|
||||||
}
|
}
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
} else if (params.statusId) {
|
} else if (params.statusId) {
|
||||||
return tpl({
|
const { description, image, title } = await getStatusInfo(params.statusId);
|
||||||
title: `View post on Ditto`,
|
res.description = description;
|
||||||
type: 'profile',
|
res.image = image;
|
||||||
description,
|
res.title = title;
|
||||||
image,
|
|
||||||
url,
|
|
||||||
site: meta.name,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const frontendController: AppMiddleware = async (c, next) => {
|
export const frontendController: AppMiddleware = async (c, next) => {
|
||||||
|
|
@ -109,7 +56,7 @@ export const frontendController: AppMiddleware = async (c, next) => {
|
||||||
const params = getPathParams(c.req.path);
|
const params = getPathParams(c.req.path);
|
||||||
|
|
||||||
if (params) {
|
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));
|
return c.html(content.replace(META_PLACEHOLDER, meta));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { NostrMetadata, NSchema as n } from '@nostrify/nostrify';
|
import { NostrMetadata, NSchema as n } from '@nostrify/nostrify';
|
||||||
import { getAuthor, getEvent } from '@/queries.ts';
|
import { getEvent } from '@/queries.ts';
|
||||||
import { nip05Cache } from '@/utils/nip05.ts';
|
|
||||||
import { match } from 'path-to-regexp';
|
import { match } from 'path-to-regexp';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { lookupAccount, lookupPubkey } from '@/utils/lookup.ts';
|
||||||
|
import { parseAndVerifyNip05 } from '@/utils/nip05.ts';
|
||||||
|
|
||||||
export interface OpenGraphTemplateOpts {
|
export interface OpenGraphTemplateOpts {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -16,6 +17,7 @@ export interface OpenGraphTemplateOpts {
|
||||||
export type PathParams = Partial<Record<'statusId' | 'acct' | 'note' | 'nevent' | 'nprofile' | 'npub', string>>;
|
export type PathParams = Partial<Record<'statusId' | 'acct' | 'note' | 'nevent' | 'nprofile' | 'npub', string>>;
|
||||||
|
|
||||||
interface StatusInfo {
|
interface StatusInfo {
|
||||||
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
image?: {
|
image?: {
|
||||||
url: string;
|
url: string;
|
||||||
|
|
@ -65,70 +67,57 @@ export function getPathParams(path: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function urlParamToPubkey(handle: string) {
|
type ProfileInfo = { name: string; about: string } & NostrMetadata;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<ProfileInfo> {
|
||||||
|
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<NostrMetadata> {
|
function truncate(s: string, len: number, ellipsis = '…') {
|
||||||
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;
|
if (s.length <= len) return s;
|
||||||
return s.slice(0, len) + ellipsis;
|
return s.slice(0, len) + ellipsis;
|
||||||
};
|
}
|
||||||
|
|
||||||
export async function getStatusInfo(id: string | undefined, handle?: string): Promise<StatusInfo> {
|
export async function getHandle(id: string, name?: string | undefined) {
|
||||||
const event = await getEvent(id || '');
|
const pubkey = /[a-z][0-9]{64}/.test(id) ? id : await lookupPubkey(id);
|
||||||
if (!event || !id) {
|
if (!pubkey) throw new Error('Invalid user identifier');
|
||||||
return { description: `A post on Ditto by @${handle}` };
|
const parsed = await parseAndVerifyNip05(id, pubkey);
|
||||||
}
|
return parsed?.handle || name || 'npub1xxx';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStatusInfo(id: string): Promise<StatusInfo> {
|
||||||
|
const event = await getEvent(id);
|
||||||
|
if (!id || !event) throw new Error('Invalid post id supplied');
|
||||||
|
|
||||||
|
const handle = await getHandle(event.pubkey);
|
||||||
const res: StatusInfo = {
|
const res: StatusInfo = {
|
||||||
|
title: `View @${handle}'s post on Ditto`,
|
||||||
description: event.content
|
description: event.content
|
||||||
.replace(/nostr:(npub1(?:[0-9]|[a-z]){58})/g, (_, key: string) => `@${key.slice(0, 8)}`),
|
.replace(/nostr:(npub1(?:[0-9]|[a-z]){58})/g, (_, key: string) => `@${key.slice(0, 8)}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
let url: string;
|
const data: string[][] = event.tags
|
||||||
let w: number;
|
.find(([name]) => name === 'imeta')?.slice(1)
|
||||||
let h: number;
|
.map((entry: string) => entry.split(' ')) ?? [];
|
||||||
|
|
||||||
for (const [tag, ...values] of event.tags) {
|
const url = data.find(([name]) => name === 'url')?.[1];
|
||||||
if (tag !== 'imeta') continue;
|
const dim = data.find(([name]) => name === 'dim')?.[1];
|
||||||
for (const value of values) {
|
|
||||||
const [item, datum] = value.split(' ');
|
const [w, h] = dim?.split('x').map(Number) ?? [null, null];
|
||||||
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) {
|
if (url && w && h) {
|
||||||
res.image = { url, w, h };
|
res.image = { url, w, h };
|
||||||
res.description = res.description.replace(url.trim(), '');
|
res.description = res.description.replace(url.trim(), '');
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue