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;
}
/** Crawler User-Agent regex to render link previews to. */
static get crawlerRegex(): string {
return 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';
static get crawlerRegex(): RegExp {
return new RegExp(
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. */
static get policy(): string {

View file

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

View file

@ -2,81 +2,19 @@ import { AppMiddleware } from '@/app.ts';
import { Conf } from '@/config.ts';
import { Stickynotes } from '@soapbox/stickynotes';
import { Storages } from '@/storages.ts';
import {
fetchProfile,
getHandle,
getPathParams,
getStatusInfo,
OpenGraphTemplateOpts,
PathParams,
} from '@/utils/og-metadata.ts';
import { getPathParams, MetadataEntities } from '@/utils/og-metadata.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');
/** Placeholder to find & replace with metadata. */
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) => {
try {
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');
console.debug('ua', ua);
if (!new RegExp(Conf.crawlerRegex, 'i').test(ua ?? '')) {
if (!Conf.crawlerRegex.test(ua ?? '')) {
return c.html(content);
}
@ -92,7 +30,8 @@ export const frontendController: AppMiddleware = async (c, next) => {
const params = getPathParams(c.req.path);
if (params) {
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));
} catch (e) {
console.log(`Error building meta tags: ${e}`);
@ -106,3 +45,30 @@ export const frontendController: AppMiddleware = async (c, 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;
reblog: MastodonStatus | null;
application: unknown;
media_attachments: unknown[];
media_attachments: {
type: string;
preview_url?: string;
meta?: {
original?: {
width?: number;
height?: number;
};
};
}[];
mentions: unknown[];
tags: unknown[];
emojis: unknown[];

View file

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

View file

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

View file

@ -1,40 +1,54 @@
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.
* @param opts the metadata to use to fill the template.
* @returns the built OpenGraph metadata.
*/
export function metadataView({ title, type, url, image, description, site }: OpenGraphTemplateOpts): string {
const res: string[] = [
html` <meta content="${title}" property="og:title">`,
html` <meta content="${type}" property="og:type">`,
html` <meta content="${url}" property="og:url">`,
html` <meta content="${site}" property="og:site_name">`,
html` <meta name="twitter:card" content="summary">`,
html` <meta name="twitter:title" content="${title}">`,
];
export function renderMetadata(url: string, { account, status, instance }: MetadataEntities): string {
const tags: string[] = [];
const title = account ? `${account.display_name} (@${account.acct})` : instance.name;
const attachment = status?.media_attachments?.find((a) => a.type === 'image');
const description = status?.content || account?.note || instance.tagline;
const image = attachment?.preview_url || account?.avatar_static || instance.picture;
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) {
res.push(html`<meta content="${description}" property="og:description">`);
res.push(html`<meta content="${description}" property="twitter:description">`);
tags.push(html`<meta name="description" content="${description}">`);
tags.push(html`<meta property="og:description" content="${description}">`);
tags.push(html`<meta name="twitter:description" content="${description}">`);
}
if (image) {
res.push(html`<meta content="${image.url}" property="og:image">`);
res.push(html`<meta name="twitter:image" content="${image.url}">`);
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">`);
tags.push(html`<meta property="og:image" content="${image}">`);
tags.push(html`<meta name="twitter:image" content="${image}">`);
}
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">`);
}
if (typeof width === 'number' && typeof height === 'number') {
tags.push(html`<meta property="og:image:width" content="${width}">`);
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('');
}