Rework opengraph

This commit is contained in:
Alex Gleason 2024-08-07 20:47:53 -05:00
parent 72970bf480
commit ba241f0431
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
8 changed files with 133 additions and 255 deletions

View file

@ -251,9 +251,12 @@ class Conf {
return optionalBooleanSchema.parse(Deno.env.get('CRON_ENABLED')) ?? true; return optionalBooleanSchema.parse(Deno.env.get('CRON_ENABLED')) ?? true;
} }
/** Crawler User-Agent regex to render link previews to. */ /** Crawler User-Agent regex to render link previews to. */
static get crawlerRegex(): string { static get crawlerRegex(): RegExp {
return Deno.env.get('CRAWLER_REGEX') || return new RegExp(
'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'; 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. */ /** Path to the custom policy module. Must be an absolute path, https:, npm:, or jsr: URI. */
static get policy(): string { static get policy(): string {

View file

@ -155,7 +155,7 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort
} }
return filters; return filters;
} catch { } catch {
// do nothing // fall through
} }
try { try {
@ -164,7 +164,7 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort
return [{ kinds: [0], authors: [pubkey] }]; return [{ kinds: [0], authors: [pubkey] }];
} }
} catch { } catch {
// do nothing // fall through
} }
return []; return [];

View file

@ -2,81 +2,19 @@ import { AppMiddleware } from '@/app.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { Stickynotes } from '@soapbox/stickynotes'; import { Stickynotes } from '@soapbox/stickynotes';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { import { getPathParams, MetadataEntities } from '@/utils/og-metadata.ts';
fetchProfile,
getHandle,
getPathParams,
getStatusInfo,
OpenGraphTemplateOpts,
PathParams,
} from '@/utils/og-metadata.ts';
import { getInstanceMetadata } from '@/utils/instance.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'); const console = new Stickynotes('ditto:frontend');
/** 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;
async function buildTemplateOpts(params: PathParams, url: string): Promise<OpenGraphTemplateOpts> {
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) => { export const frontendController: AppMiddleware = async (c, next) => {
try { try {
const content = await Deno.readTextFile(new URL('../../public/index.html', import.meta.url)); 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'); const ua = c.req.header('User-Agent');
console.debug('ua', ua); console.debug('ua', ua);
if (!new RegExp(Conf.crawlerRegex, 'i').test(ua ?? '')) { if (!Conf.crawlerRegex.test(ua ?? '')) {
return c.html(content); return c.html(content);
} }
@ -92,7 +30,8 @@ export const frontendController: AppMiddleware = async (c, next) => {
const params = getPathParams(c.req.path); const params = getPathParams(c.req.path);
if (params) { if (params) {
try { 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)); return c.html(content.replace(META_PLACEHOLDER, meta));
} catch (e) { } catch (e) {
console.log(`Error building meta tags: ${e}`); console.log(`Error building meta tags: ${e}`);
@ -106,3 +45,30 @@ export const frontendController: AppMiddleware = async (c, next) => {
await next(); await next();
} }
}; };
async function getEntities(params: { acct?: string; statusId?: string }): Promise<MetadataEntities> {
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;
}

View file

@ -24,7 +24,16 @@ export interface MastodonStatus {
pinned: boolean; pinned: boolean;
reblog: MastodonStatus | null; reblog: MastodonStatus | null;
application: unknown; application: unknown;
media_attachments: unknown[]; media_attachments: {
type: string;
preview_url?: string;
meta?: {
original?: {
width?: number;
height?: number;
};
};
}[];
mentions: unknown[]; mentions: unknown[];
tags: unknown[]; tags: unknown[];
emojis: unknown[]; emojis: unknown[];

View file

@ -75,7 +75,7 @@ export function extractIdentifier(value: string): string | undefined {
} }
} }
} catch { } catch {
// do nothing // fall through
} }
value = value.replace(/^@/, ''); value = value.replace(/^@/, '');

View file

@ -1,35 +1,20 @@
import { NostrEvent, NostrMetadata, NSchema as n } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools';
import { Stickynotes } from '@soapbox/stickynotes';
import { nip19, nip27 } from 'nostr-tools';
import { match } from 'path-to-regexp'; import { match } from 'path-to-regexp';
import { getAuthor, getEvent } from '@/queries.ts'; import { MastodonAccount } from '@/entities/MastodonAccount.ts';
import { lookupPubkey } from '@/utils/lookup.ts'; import { MastodonStatus } from '@/entities/MastodonStatus.ts';
import { parseAndVerifyNip05 } from '@/utils/nip05.ts'; import { InstanceMetadata } from '@/utils/instance.ts';
import { parseNip05 } from '@/utils.ts';
const console = new Stickynotes('ditto:frontend'); export interface MetadataEntities {
status?: MastodonStatus;
export interface OpenGraphTemplateOpts { account?: MastodonAccount;
title: string; instance: InstanceMetadata;
type: 'article' | 'profile' | 'website';
url: string;
image?: StatusInfo['image'];
description?: string;
site: string;
} }
export type PathParams = Partial<Record<'statusId' | 'acct' | 'note' | 'nevent' | 'nprofile' | 'npub', string>>; export interface MetadataPathParams {
statusId?: string;
interface StatusInfo { acct?: string;
title: string; bech32?: string;
description: string;
image?: {
url: string;
w?: number;
h?: number;
alt?: string;
};
} }
/** URL routes to serve metadata on. */ /** URL routes to serve metadata on. */
@ -42,139 +27,40 @@ const SSR_ROUTES = [
'/statuses/:statusId', '/statuses/:statusId',
'/notice/:statusId', '/notice/:statusId',
'/posts/:statusId', '/posts/:statusId',
'/note:note', '/:bech32',
'/nevent:nevent',
'/nprofile:nprofile',
'/npub:npub',
] as const; ] 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) { for (const matcher of SSR_ROUTE_MATCHERS) {
const result = matcher(path); const result = matcher(path);
if (!result) continue; if (!result) continue;
const params = result.params as PathParams;
if (params.nevent) { const params: MetadataPathParams = result.params;
const decoded = nip19.decode(`nevent${params.nevent}`).data as nip19.EventPointer;
params.statusId = decoded.id; if (params.bech32) {
} else if (params.note) { try {
params.statusId = nip19.decode(`note${params.note}`).data as string; 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; return params;
} }
} }
export async function fetchProfile(
{ pubkey, handle }: Partial<Record<'pubkey' | 'handle', string>>,
): Promise<ProfileInfo> {
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<StatusInfo> {
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;
}

View file

@ -18,7 +18,7 @@ export async function getRelays(store: NStore, pubkey: string): Promise<Set<stri
relays.add(url.toString() as `wss://${string}`); relays.add(url.toString() as `wss://${string}`);
} }
} catch (_e) { } catch (_e) {
// do nothing // fall through
} }
} }
} }

View file

@ -1,40 +1,54 @@
import { html } from '@/utils/html.ts'; import { html } from '@/utils/html.ts';
import { OpenGraphTemplateOpts } from '@/utils/og-metadata.ts'; import { MetadataEntities } from '@/utils/og-metadata.ts';
/** /**
* Builds a series of meta tags from supplied metadata for injection into the served HTML page. * 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. * @param opts the metadata to use to fill the template.
* @returns the built OpenGraph metadata. * @returns the built OpenGraph metadata.
*/ */
export function metadataView({ title, type, url, image, description, site }: OpenGraphTemplateOpts): string { export function renderMetadata(url: string, { account, status, instance }: MetadataEntities): string {
const res: string[] = [ const tags: string[] = [];
html` <meta content="${title}" property="og:title">`,
html` <meta content="${type}" property="og:type">`, const title = account ? `${account.display_name} (@${account.acct})` : instance.name;
html` <meta content="${url}" property="og:url">`, const attachment = status?.media_attachments?.find((a) => a.type === 'image');
html` <meta content="${site}" property="og:site_name">`, const description = status?.content || account?.note || instance.tagline;
html` <meta name="twitter:card" content="summary">`, const image = attachment?.preview_url || account?.avatar_static || instance.picture;
html` <meta name="twitter:title" content="${title}">`, const siteName = instance?.name;
]; const width = attachment?.meta?.original?.width;
const height = attachment?.meta?.original?.height;
if (title) {
tags.push(html`<title>${title}</title>`);
tags.push(html`<meta property="og:title" content="${title}">`);
tags.push(html`<meta name="twitter:title" content="${title}">`);
}
if (description) { if (description) {
res.push(html`<meta content="${description}" property="og:description">`); tags.push(html`<meta name="description" content="${description}">`);
res.push(html`<meta content="${description}" property="twitter:description">`); tags.push(html`<meta property="og:description" content="${description}">`);
tags.push(html`<meta name="twitter:description" content="${description}">`);
} }
if (image) { if (image) {
res.push(html`<meta content="${image.url}" property="og:image">`); tags.push(html`<meta property="og:image" content="${image}">`);
res.push(html`<meta name="twitter:image" content="${image.url}">`); tags.push(html`<meta name="twitter:image" content="${image}">`);
if (image.w && image.h) {
res.push(html`<meta content="${image.w}" property="og:image:width">`);
res.push(html`<meta content="${image.h}" property="og:image:height">`);
} }
if (image.alt) { if (typeof width === 'number' && typeof height === 'number') {
res.push(html`<meta content="${image.alt}" property="og:image:alt">`); tags.push(html`<meta property="og:image:width" content="${width}">`);
res.push(html`<meta content="${image.alt}" property="twitter:image:alt">`); tags.push(html`<meta property="og:image:height" content="${height}">`);
}
} }
return res.join(''); if (siteName) {
tags.push(html`<meta property="og:site_name" content="${siteName}">`);
}
// Extra tags (always present if other tags exist).
if (tags.length > 0) {
tags.push(html`<meta property="og:url" content="${url}">`);
tags.push('<meta property="og:type" content="website">');
tags.push('<meta name="twitter:card" content="summary">');
}
return tags.join('');
} }